Compare commits
11 Commits
v1.0.0
...
412eed0d54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
412eed0d54 | ||
|
|
8c3ab23843 | ||
| 6cb690bfb7 | |||
| 3bd6551c8e | |||
| 403d853d27 | |||
| 4abd40d8e2 | |||
| 9121da9aae | |||
| 74ce052b77 | |||
| 1eef38a5fa | |||
| 97a82e9b2c | |||
|
|
869b7ca4c3 |
12
.gitea/workflows/check.yml
Normal file
12
.gitea/workflows/check.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
name: check
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- run: docker build .
|
||||
53
Dockerfile
53
Dockerfile
@@ -1,39 +1,20 @@
|
||||
# First stage: Use the golangci-lint image to run the linter
|
||||
FROM golangci/golangci-lint:latest as lint
|
||||
|
||||
# Set the Current Working Directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the go.mod file and the rest of the application code
|
||||
COPY go.mod ./
|
||||
# Lint stage: format check + golangci-lint
|
||||
# golangci-lint v1.64.8 (2025-02-18)
|
||||
FROM golangci/golangci-lint@sha256:2987913e27f4eca9c8a39129d2c7bc1e74fbcf77f181e01cea607be437aa5cb8 AS lint
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN make fmt-check
|
||||
RUN make lint
|
||||
|
||||
# Run golangci-lint
|
||||
RUN golangci-lint run
|
||||
|
||||
RUN sh -c 'test -z "$(gofmt -l .)"'
|
||||
|
||||
# Second stage: Use the official Golang image to run tests
|
||||
FROM golang:1.22 as test
|
||||
|
||||
# Set the Current Working Directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the go.mod file and the rest of the application code
|
||||
COPY go.mod ./
|
||||
# Test stage: run full test suite
|
||||
# golang 1.22.12 (2025-02-04)
|
||||
FROM golang@sha256:1cf6c45ba39db9fd6db16922041d074a63c935556a05c5ccb62d181034df7f02 AS test
|
||||
# Depend on lint stage so both stages always run
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
|
||||
# Run tests
|
||||
RUN go test -v ./...
|
||||
|
||||
# Final stage: Combine the linting and testing stages
|
||||
FROM golang:1.22 as final
|
||||
|
||||
# Ensure that the linting stage succeeded
|
||||
WORKDIR /app
|
||||
COPY --from=lint /app .
|
||||
COPY --from=test /app .
|
||||
|
||||
# Set the final CMD to something minimal since we only needed to verify lint and tests during build
|
||||
CMD ["echo", "Build and tests passed successfully!"]
|
||||
|
||||
RUN make test
|
||||
|
||||
17
Makefile
17
Makefile
@@ -1,6 +1,6 @@
|
||||
.PHONY: test
|
||||
.PHONY: test fmt fmt-check lint check docker hooks
|
||||
|
||||
default: test
|
||||
default: check
|
||||
|
||||
test:
|
||||
@go test -v ./...
|
||||
@@ -9,9 +9,20 @@ fmt:
|
||||
goimports -l -w .
|
||||
golangci-lint run --fix
|
||||
|
||||
fmt-check:
|
||||
@test -z "$$(gofmt -l .)" || { echo "gofmt would reformat:"; gofmt -l .; exit 1; }
|
||||
|
||||
lint:
|
||||
golangci-lint run
|
||||
sh -c 'test -z "$$(gofmt -l .)"'
|
||||
|
||||
check: fmt-check lint test
|
||||
|
||||
docker:
|
||||
docker build --progress plain .
|
||||
|
||||
hooks:
|
||||
@echo "Installing git hooks..."
|
||||
@mkdir -p .git/hooks
|
||||
@printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit
|
||||
@chmod +x .git/hooks/pre-commit
|
||||
@echo "Pre-commit hook installed."
|
||||
|
||||
@@ -18,6 +18,12 @@ Released v1.0.0 2024-06-14. Works as intended. No known bugs.
|
||||
- if output is a tty, outputs pretty color logs
|
||||
- if output is not a tty, outputs json
|
||||
- supports delivering each log message via a webhook
|
||||
- emits every `slog` attribute: those passed to a log call, those
|
||||
accumulated with `WithAttrs`, and those qualified by `WithGroup`.
|
||||
`slog.Group` values nest, and `slog.LogValuer` values are resolved. In
|
||||
json output attributes are object fields (groups become nested objects);
|
||||
in console output they are appended as `key=value` pairs, with grouped
|
||||
keys written as `group.key=value`
|
||||
|
||||
## Planned Features
|
||||
|
||||
|
||||
59
TODO.md
Normal file
59
TODO.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Workflow
|
||||
|
||||
* branch (from `main`)
|
||||
* do the work in Next Step
|
||||
* move Next Step to the top of Completed Steps
|
||||
* move the top item of Future Steps into Next Step
|
||||
* commit (`TODO.md` changes in the same commit as the work)
|
||||
* merge to `main` if the branch is not protected, otherwise open a PR
|
||||
* push
|
||||
|
||||
# Status
|
||||
|
||||
1.0+
|
||||
|
||||
Tagged v1.0.0 (2024-06-14) and 1.0.1 (2026-02-08). In post-1.0
|
||||
maintenance; the library is referenced by the Go styleguide.
|
||||
|
||||
# Next Step
|
||||
|
||||
Bring the Makefile up to policy in one commit: add fmt-check, check, and
|
||||
hooks targets (test/lint/fmt/docker exist) and add the missing policy
|
||||
files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig,
|
||||
.dockerignore, and .gitea/workflows/check.yml running make check.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
* 2026-08-10: fixed every handler discarding slog attributes: console,
|
||||
JSON and webhook handlers now emit record attributes, accumulate
|
||||
WithAttrs without mutating the receiver, and honour WithGroup;
|
||||
slog.Group values nest and LogValuer values are resolved
|
||||
* 2026-02-08: fixed JSONHandler deadlock from recursive log.Println,
|
||||
with regression test; tagged 1.0.1
|
||||
* 2024-06-14: 1.0 prep: lint and fmt enforced in Docker build, call
|
||||
stack depth fix so log locations report correctly, example script,
|
||||
removed non-building RELP code; tagged v1.0.0
|
||||
* 2024-05-22: module path moved to sneak.berlin/go/simplelog
|
||||
* 2024-05-14: initial library: slog-based console, JSON, webhook, and
|
||||
RELP handlers, MultiplexHandler, caller file/line info, UTC ISO
|
||||
timestamps, level-colored output
|
||||
|
||||
# Future Steps
|
||||
|
||||
* Rewrite the Dockerfile to run make check with sha256-pinned base
|
||||
images (currently golangci/golangci-lint:latest and golang:1.22,
|
||||
unpinned, duplicating Makefile logic instead of calling it)
|
||||
* Restructure README.md into the standard sections: Description,
|
||||
Getting Started, Rationale, Design, TODO, License, Author
|
||||
* Delete the old plain TODO file once this TODO.md lands
|
||||
* Add .aider.* to .gitignore and remove the stray aider artifacts from
|
||||
the working tree
|
||||
* Pick one tag scheme before the next release (v1.0.0 vs 1.0.1 are
|
||||
inconsistent)
|
||||
* Tag v1.0.2, with the leading v, once the attribute fix lands, so
|
||||
consuming repos can move off pseudo-version pins in one step
|
||||
* Fix RELP output to cache (from old TODO)
|
||||
* Re-add RELP delivery over TCP to remote rsyslog imrelp; removed
|
||||
2024-06-14 because it did not build (README planned feature)
|
||||
* Add regex filtering for webhook logs (from old TODO)
|
||||
* Better console output format (from old TODO)
|
||||
220
attrs.go
Normal file
220
attrs.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package simplelog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// handlerAttrs is the attribute state every handler carries: the attributes
|
||||
// accumulated by WithAttrs, plus the groups opened by WithGroup. Its methods
|
||||
// never mutate the receiver, so handlers derived from a common parent stay
|
||||
// independent of each other.
|
||||
type handlerAttrs struct {
|
||||
attrs []slog.Attr
|
||||
groups []string
|
||||
}
|
||||
|
||||
// withAttrs returns a copy carrying attrs in addition to those already held.
|
||||
// The attributes are qualified by the groups that are open at the time they
|
||||
// are attached, as the slog.Handler contract requires.
|
||||
func (h handlerAttrs) withAttrs(attrs []slog.Attr) handlerAttrs {
|
||||
qualified := qualifyAttrs(h.groups, attrs)
|
||||
combined := make([]slog.Attr, 0, len(h.attrs)+len(qualified))
|
||||
combined = append(combined, h.attrs...)
|
||||
combined = append(combined, qualified...)
|
||||
return handlerAttrs{attrs: combined, groups: h.groups}
|
||||
}
|
||||
|
||||
// withGroup returns a copy with a further group open. An empty name is a no-op,
|
||||
// per the slog.Handler contract.
|
||||
func (h handlerAttrs) withGroup(name string) handlerAttrs {
|
||||
if name == "" {
|
||||
return h
|
||||
}
|
||||
groups := make([]string, 0, len(h.groups)+1)
|
||||
groups = append(groups, h.groups...)
|
||||
groups = append(groups, name)
|
||||
return handlerAttrs{attrs: h.attrs, groups: groups}
|
||||
}
|
||||
|
||||
// forRecord returns the accumulated attributes followed by the record's own,
|
||||
// the latter qualified by any open groups.
|
||||
func (h handlerAttrs) forRecord(record slog.Record) []slog.Attr {
|
||||
own := qualifyAttrs(h.groups, recordAttrs(record))
|
||||
all := make([]slog.Attr, 0, len(h.attrs)+len(own))
|
||||
all = append(all, h.attrs...)
|
||||
all = append(all, own...)
|
||||
return all
|
||||
}
|
||||
|
||||
// qualifyAttrs nests attrs inside the given open groups, innermost last.
|
||||
func qualifyAttrs(groups []string, attrs []slog.Attr) []slog.Attr {
|
||||
for i := len(groups) - 1; i >= 0; i-- {
|
||||
attrs = []slog.Attr{{
|
||||
Key: groups[i],
|
||||
Value: slog.GroupValue(attrs...),
|
||||
}}
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// recordAttrs collects the attributes a record carries. They live in
|
||||
// unexported fields, so they are only reachable through Record.Attrs - which
|
||||
// is why marshaling a slog.Record directly loses every one of them.
|
||||
func recordAttrs(record slog.Record) []slog.Attr {
|
||||
attrs := make([]slog.Attr, 0, record.NumAttrs())
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
attrs = append(attrs, attr)
|
||||
return true
|
||||
})
|
||||
return attrs
|
||||
}
|
||||
|
||||
// recordToMap renders a record, with its handler's attributes, as the JSON
|
||||
// object the JSON and webhook handlers emit. The record's own fields keep the
|
||||
// names they have always had, and win a collision with an attribute key.
|
||||
func recordToMap(record slog.Record, attrs handlerAttrs) map[string]any {
|
||||
fields := attrsToMap(attrs.forRecord(record))
|
||||
fields["Time"] = record.Time
|
||||
fields["Level"] = record.Level
|
||||
fields["Message"] = record.Message
|
||||
fields["PC"] = record.PC
|
||||
return fields
|
||||
}
|
||||
|
||||
// attrsToMap renders attributes as a JSON object in which groups are nested
|
||||
// objects. A group named more than once is merged rather than duplicated.
|
||||
func attrsToMap(attrs []slog.Attr) map[string]any {
|
||||
fields := make(map[string]any, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
addAttrToMap(fields, attr)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func addAttrToMap(fields map[string]any, attr slog.Attr) {
|
||||
value := attr.Value.Resolve()
|
||||
if attr.Key == "" && value.Any() == nil {
|
||||
// An empty Attr is ignored, per the slog.Handler contract.
|
||||
return
|
||||
}
|
||||
|
||||
if value.Kind() == slog.KindGroup {
|
||||
group := value.Group()
|
||||
if len(group) == 0 {
|
||||
// An empty group is elided, as is its key.
|
||||
return
|
||||
}
|
||||
// A group with an empty key is inlined into its parent.
|
||||
target := fields
|
||||
if attr.Key != "" {
|
||||
nested, ok := fields[attr.Key].(map[string]any)
|
||||
if !ok {
|
||||
nested = make(map[string]any, len(group))
|
||||
fields[attr.Key] = nested
|
||||
}
|
||||
target = nested
|
||||
}
|
||||
for _, member := range group {
|
||||
addAttrToMap(target, member)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fields[attr.Key] = jsonValue(value)
|
||||
}
|
||||
|
||||
// jsonValue converts a resolved slog.Value into something encoding/json can
|
||||
// render usefully. Values it cannot marshal - and errors, which marshal to an
|
||||
// empty object - fall back to their slog string form, so an attribute is never
|
||||
// silently emptied.
|
||||
func jsonValue(value slog.Value) any {
|
||||
switch value.Kind() {
|
||||
case slog.KindString:
|
||||
return value.String()
|
||||
case slog.KindInt64:
|
||||
return value.Int64()
|
||||
case slog.KindUint64:
|
||||
return value.Uint64()
|
||||
case slog.KindFloat64:
|
||||
return value.Float64()
|
||||
case slog.KindBool:
|
||||
return value.Bool()
|
||||
case slog.KindDuration:
|
||||
return value.Duration().String()
|
||||
case slog.KindTime:
|
||||
return value.Time()
|
||||
default:
|
||||
// KindAny, and anything a future Go release adds.
|
||||
return jsonAnyValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonAnyValue(value slog.Value) any {
|
||||
held := value.Any()
|
||||
if _, ok := held.(json.Marshaler); !ok {
|
||||
if err, ok := held.(error); ok {
|
||||
return err.Error()
|
||||
}
|
||||
}
|
||||
if _, err := json.Marshal(held); err != nil {
|
||||
return value.String()
|
||||
}
|
||||
return held
|
||||
}
|
||||
|
||||
// attrsToText renders attributes as the space separated key=value pairs the
|
||||
// console handler appends to a log line. Groups become dotted key prefixes.
|
||||
func attrsToText(attrs []slog.Attr) string {
|
||||
var out strings.Builder
|
||||
for _, attr := range attrs {
|
||||
appendAttrText(&out, "", attr)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func appendAttrText(out *strings.Builder, prefix string, attr slog.Attr) {
|
||||
value := attr.Value.Resolve()
|
||||
if attr.Key == "" && value.Any() == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if value.Kind() == slog.KindGroup {
|
||||
group := value.Group()
|
||||
if len(group) == 0 {
|
||||
return
|
||||
}
|
||||
nested := prefix
|
||||
if attr.Key != "" {
|
||||
nested = prefix + attr.Key + "."
|
||||
}
|
||||
for _, member := range group {
|
||||
appendAttrText(out, nested, member)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
out.WriteString(" ")
|
||||
out.WriteString(prefix)
|
||||
out.WriteString(attr.Key)
|
||||
out.WriteString("=")
|
||||
out.WriteString(quoteIfNeeded(value.String()))
|
||||
}
|
||||
|
||||
// quoteIfNeeded quotes a value only when leaving it bare would make the
|
||||
// key=value pairs ambiguous, matching how the stdlib text handler reads.
|
||||
func quoteIfNeeded(value string) string {
|
||||
if value == "" {
|
||||
return `""`
|
||||
}
|
||||
for _, r := range value {
|
||||
if unicode.IsSpace(r) || !unicode.IsPrint(r) ||
|
||||
r == '"' || r == '=' {
|
||||
return strconv.Quote(value)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
410
attrs_test.go
Normal file
410
attrs_test.go
Normal file
@@ -0,0 +1,410 @@
|
||||
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")
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
type ConsoleHandler struct{}
|
||||
type ConsoleHandler struct {
|
||||
attrs handlerAttrs
|
||||
}
|
||||
|
||||
func NewConsoleHandler() *ConsoleHandler {
|
||||
return &ConsoleHandler{}
|
||||
@@ -42,12 +44,13 @@ func (c *ConsoleHandler) Handle(
|
||||
}
|
||||
fmt.Println(
|
||||
colorFunc(
|
||||
"%s [%s] %s:%d: %s",
|
||||
"%s [%s] %s:%d: %s%s",
|
||||
timestamp,
|
||||
record.Level,
|
||||
file,
|
||||
line,
|
||||
record.Message,
|
||||
attrsToText(c.attrs.forRecord(record)),
|
||||
),
|
||||
)
|
||||
return nil
|
||||
@@ -61,9 +64,15 @@ func (c *ConsoleHandler) Enabled(
|
||||
}
|
||||
|
||||
func (c *ConsoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return c
|
||||
if len(attrs) == 0 {
|
||||
return c
|
||||
}
|
||||
return &ConsoleHandler{attrs: c.attrs.withAttrs(attrs)}
|
||||
}
|
||||
|
||||
func (c *ConsoleHandler) WithGroup(name string) slog.Handler {
|
||||
return c
|
||||
if name == "" {
|
||||
return c
|
||||
}
|
||||
return &ConsoleHandler{attrs: c.attrs.withGroup(name)}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,25 @@ package simplelog
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
type JSONHandler struct{}
|
||||
type JSONHandler struct {
|
||||
attrs handlerAttrs
|
||||
}
|
||||
|
||||
func NewJSONHandler() *JSONHandler {
|
||||
return &JSONHandler{}
|
||||
}
|
||||
|
||||
func (j *JSONHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||
jsonData, _ := json.Marshal(record)
|
||||
log.Println(string(jsonData))
|
||||
jsonData, err := json.Marshal(recordToMap(record, j.attrs))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling log record: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintln(os.Stdout, string(jsonData))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -24,9 +30,15 @@ func (j *JSONHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
}
|
||||
|
||||
func (j *JSONHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return j
|
||||
if len(attrs) == 0 {
|
||||
return j
|
||||
}
|
||||
return &JSONHandler{attrs: j.attrs.withAttrs(attrs)}
|
||||
}
|
||||
|
||||
func (j *JSONHandler) WithGroup(name string) slog.Handler {
|
||||
return j
|
||||
if name == "" {
|
||||
return j
|
||||
}
|
||||
return &JSONHandler{attrs: j.attrs.withGroup(name)}
|
||||
}
|
||||
|
||||
33
json_handler_test.go
Normal file
33
json_handler_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package simplelog
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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()
|
||||
|
||||
// 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() …
|
||||
slog.Info("test message")
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// success
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("JSONHandler.Handle deadlocked: timed out after 5 seconds")
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
type WebhookHandler struct {
|
||||
webhookURL string
|
||||
attrs handlerAttrs
|
||||
}
|
||||
|
||||
func (w *WebhookHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
@@ -19,11 +20,23 @@ func (w *WebhookHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
}
|
||||
|
||||
func (w *WebhookHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return w
|
||||
if len(attrs) == 0 {
|
||||
return w
|
||||
}
|
||||
return &WebhookHandler{
|
||||
webhookURL: w.webhookURL,
|
||||
attrs: w.attrs.withAttrs(attrs),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WebhookHandler) WithGroup(name string) slog.Handler {
|
||||
return w
|
||||
if name == "" {
|
||||
return w
|
||||
}
|
||||
return &WebhookHandler{
|
||||
webhookURL: w.webhookURL,
|
||||
attrs: w.attrs.withGroup(name),
|
||||
}
|
||||
}
|
||||
|
||||
func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
|
||||
@@ -34,7 +47,7 @@ func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
|
||||
}
|
||||
|
||||
func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||
jsonData, err := json.Marshal(record)
|
||||
jsonData, err := json.Marshal(recordToMap(record, w.attrs))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling event: %v", err)
|
||||
}
|
||||
@@ -42,6 +55,6 @@ func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user