1 Commits
Author SHA1 Message Date
sneak bbf73fe31d Run the linter only in Docker (closes #20)
check / check (push) Successful in 40s
check / check (pull_request) Successful in 39s
script/lint now builds only the lint stage of the Dockerfile, without
the build cache, so every run executes the linter; the image is tagged
simplelog-lint. The lint stage calls golangci-lint directly, since make
lint is itself a docker build of that stage. script/cibuild and
script/docker also build without the cache, so their check steps always
run. script/fmt no longer runs golangci-lint --fix, and script/bootstrap
no longer installs it. golangci-lint config verify is left out, on the
owner's ruling. The README, TODO.md and the script/cibuild comment say
what now runs.

Model: opus-5-5
2026-10-06 06:30:41 +00:00
9 changed files with 27 additions and 283 deletions
+1 -28
View File
@@ -25,9 +25,6 @@ Released v1.0.0 2024-06-14. Works as intended. No known bugs.
in console output they are appended as `key=value` pairs, with grouped
keys written as `group.key=value`. See
[Attribute output](#attribute-output) for the details worth knowing
- reports a record that could not be delivered as an error from the
handler's `Handle` method. See
[When delivery fails](#when-delivery-fails) for how to receive it
## Planned Features
@@ -107,28 +104,6 @@ ignored, an empty group is elided along with its key, a group with an
empty key is inlined into its parent, and `WithGroup("")` returns the
handler unchanged.
## When delivery fails
Every handler returns an error from `Handle` when it could not deliver
the record:
- `ConsoleHandler` and `JSONHandler` return the error from their write to
stdout, wrapped, so `errors.Is` still matches the original
- `WebhookHandler` returns an error when the request fails, and also when
the server answers with a status outside 2xx, a redirect included,
since it does not follow redirects
- `MultiplexHandler`, which simplelog installs as the default, passes the
record to every handler it holds even after one of them fails, then
returns all their errors joined with `errors.Join` (nil if none failed)
`slog.Info`, `slog.Error` and the other `slog.Logger` methods throw that
error away. That is how `log/slog` works, and simplelog cannot change it.
To find out whether a record was delivered, build a `slog.Record` and pass
it to the handler yourself, for example
`slog.Default().Handler().Handle(ctx, record)`, then check the error it
returns. A record passed to `Handle` directly gets the wrong file and
line in console output.
## Entrypoints
This repository adheres to the
@@ -155,9 +130,7 @@ alpine. We provide:
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension)
- `script/docker` — build the Docker image without the build cache, tagged
via `script/projectname` (byte-identical across repos); it also passes the
output of `git describe` as the `VERSION` build argument, which is ignored
because this `Dockerfile` does not declare it
via `script/projectname` (byte-identical across repos)
- `script/cibuild` — cd to the repo root and build the Docker image without the
build cache, tagged via `script/projectname` (what CI runs; the image build
runs the checks)
+3 -4
View File
@@ -28,10 +28,6 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig,
`lint` stage of the `Dockerfile`, every `docker build` in `script/`
runs without the build cache, and `script/bootstrap` no longer
installs golangci-lint
* 2026-10-06: every handler now returns a failed delivery from `Handle`
instead of discarding it: console and JSON return the stdout write
error, the webhook also fails on a non-2xx answer, and the multiplex
delivers to every handler and returns their errors joined
* 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;
@@ -55,6 +51,9 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig,
# 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
+5 -17
View File
@@ -3,7 +3,6 @@ package simplelog
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"runtime"
@@ -18,9 +17,6 @@ const callerSkipFrames = 4
// ConsoleHandler writes human-readable, colored log lines to stdout.
type ConsoleHandler struct {
// out is where records are written. Nil means os.Stdout, looked up on
// every write so that a reassigned os.Stdout is followed.
out io.Writer
attrs handlerAttrs
}
@@ -31,7 +27,7 @@ func NewConsoleHandler() *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. A failed write is returned as an error.
// key=value pairs.
func (c *ConsoleHandler) Handle(
_ context.Context,
record slog.Record,
@@ -60,13 +56,8 @@ func (c *ConsoleHandler) Handle(
line = 0
}
out := c.out
if out == nil {
out = os.Stdout
}
_, err := fmt.Fprintln(
out,
_, _ = fmt.Fprintln(
os.Stdout,
colorFunc(
"%s [%s] %s:%d: %s%s",
timestamp,
@@ -77,9 +68,6 @@ func (c *ConsoleHandler) Handle(
attrsToText(c.attrs.forRecord(record)),
),
)
if err != nil {
return fmt.Errorf("error writing log record: %w", err)
}
return nil
}
@@ -100,7 +88,7 @@ func (c *ConsoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return c
}
return &ConsoleHandler{out: c.out, attrs: c.attrs.withAttrs(attrs)}
return &ConsoleHandler{attrs: c.attrs.withAttrs(attrs)}
}
// WithGroup returns a new handler that qualifies later attributes with
@@ -110,5 +98,5 @@ func (c *ConsoleHandler) WithGroup(name string) slog.Handler {
return c
}
return &ConsoleHandler{out: c.out, attrs: c.attrs.withGroup(name)}
return &ConsoleHandler{attrs: c.attrs.withGroup(name)}
}
-162
View File
@@ -1,162 +0,0 @@
package simplelog
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// These tests sit inside the package so they can point a handler at a sink
// that fails, which callers outside the package cannot do.
// errSinkFailed is what failingWriter returns from every write.
var errSinkFailed = errors.New("sink failed")
// failingWriter is a sink whose every write fails.
type failingWriter struct{}
func (failingWriter) Write(_ []byte) (int, error) {
return 0, errSinkFailed
}
func errorTestRecord() slog.Record {
return slog.NewRecord(time.Now(), slog.LevelInfo, "casting", 0)
}
// The handler is derived with WithAttrs and WithGroup, so the test also
// fails if either of them drops the sink.
func TestJSONHandlerReturnsWriteError(t *testing.T) {
t.Parallel()
handler := (&JSONHandler{out: failingWriter{}}).
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}).
WithGroup("cast")
err := handler.Handle(context.Background(), errorTestRecord())
if !errors.Is(err, errSinkFailed) {
t.Fatalf("Handle returned %v, want the sink's error", err)
}
}
func TestConsoleHandlerReturnsWriteError(t *testing.T) {
t.Parallel()
handler := (&ConsoleHandler{out: failingWriter{}}).
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}).
WithGroup("cast")
err := handler.Handle(context.Background(), errorTestRecord())
if !errors.Is(err, errSinkFailed) {
t.Fatalf("Handle returned %v, want the sink's error", err)
}
}
// A failing handler must not stop the record reaching the handlers after
// it, and its error must still reach the caller.
func TestMultiplexHandlerDeliversPastFailingHandler(t *testing.T) {
t.Parallel()
var delivered bytes.Buffer
handler := &MultiplexHandler{handlers: []ExtendedHandler{
&JSONHandler{out: failingWriter{}},
&JSONHandler{out: &delivered},
}}
err := handler.Handle(context.Background(), errorTestRecord())
if !errors.Is(err, errSinkFailed) {
t.Fatalf("Handle returned %v, want the failing handler's error", err)
}
if !strings.Contains(delivered.String(), `"Message":"casting"`) {
t.Fatalf(
"second handler did not receive the record: %q",
delivered.String(),
)
}
}
// When more than one handler fails, the caller gets every failure, not
// only the first.
func TestMultiplexHandlerReturnsEveryFailure(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
},
))
defer server.Close()
webhook, err := NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
handler := &MultiplexHandler{handlers: []ExtendedHandler{
&JSONHandler{out: failingWriter{}},
webhook,
}}
err = handler.Handle(context.Background(), errorTestRecord())
if !errors.Is(err, errSinkFailed) {
t.Fatalf("Handle returned %v, want the JSON handler's error", err)
}
if !errors.Is(err, errWebhookStatus) {
t.Fatalf("Handle returned %v, want the webhook's error", err)
}
}
func TestWebhookHandlerReturnsErrorOnServerError(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
},
))
defer server.Close()
handler, err := NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
err = handler.Handle(context.Background(), errorTestRecord())
if !errors.Is(err, errWebhookStatus) {
t.Fatalf("Handle returned %v, want an error for the 500 answer", err)
}
}
// Following the redirect would resend the request as a GET without the
// record, and the GET is answered with 200, so only an error for the
// redirect itself tells the caller the record was lost.
func TestWebhookHandlerReturnsErrorOnRedirect(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/moved" {
http.Redirect(w, r, "/moved", http.StatusFound)
}
},
))
defer server.Close()
handler, err := NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
err = handler.Handle(context.Background(), errorTestRecord())
if !errors.Is(err, errWebhookStatus) {
t.Fatalf("Handle returned %v, want an error for the redirect", err)
}
}
+4 -16
View File
@@ -4,16 +4,12 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
)
// JSONHandler writes each log record to stdout as a JSON document.
type JSONHandler struct {
// out is where records are written. Nil means os.Stdout, looked up on
// every write so that a reassigned os.Stdout is followed.
out io.Writer
attrs handlerAttrs
}
@@ -23,22 +19,14 @@ func NewJSONHandler() *JSONHandler {
}
// Handle marshals the record, with its attributes, to one JSON object and
// writes it to stdout. A failed write is returned as an error.
// 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 err
}
out := j.out
if out == nil {
out = os.Stdout
}
_, err = fmt.Fprintln(out, string(jsonData))
if err != nil {
return fmt.Errorf("error writing log record: %w", err)
}
_, _ = fmt.Fprintln(os.Stdout, string(jsonData))
return nil
}
@@ -56,7 +44,7 @@ func (j *JSONHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return j
}
return &JSONHandler{out: j.out, attrs: j.attrs.withAttrs(attrs)}
return &JSONHandler{attrs: j.attrs.withAttrs(attrs)}
}
// WithGroup returns a new handler that nests later attributes in an
@@ -66,5 +54,5 @@ func (j *JSONHandler) WithGroup(name string) slog.Handler {
return j
}
return &JSONHandler{out: j.out, attrs: j.attrs.withGroup(name)}
return &JSONHandler{attrs: j.attrs.withGroup(name)}
}
+2 -12
View File
@@ -1,8 +1,7 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
# --no-cache because the gate phases the final stage depends on are RUN
# steps, and a cached one is a check that did not run.
# --no-cache because a cached check step is a check that did not run.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -10,16 +9,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. VERSION is computed here because .dockerignore
# excludes .git, so `git describe` in a build stage yields an empty
# version without failing.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
docker build --no-cache -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"
+5 -9
View File
@@ -1,13 +1,9 @@
#!/bin/sh
# script/lint: run the linter. Linting is a phase of the Dockerfile and
# this builds that phase alone; the linter is never installed or run on
# a developer host, where a shared result cache and a host-global lock
# make its answer untrustworthy.
#
# The phase is not the last stage in the file, so it is built only when
# --target names it. --no-cache because a cached lint layer is a lint
# that did not run. The tag makes each build replace the previous image
# instead of leaving a dangling one behind.
# script/lint: run the linter, in docker only, by building the lint stage
# of the Dockerfile and nothing else; the build fails on any finding.
# --no-cache makes every run execute the linter: a cached build of an
# unchanged tree succeeds without linting anything. The tag makes each
# build replace the previous image instead of leaving an untagged one.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
+4 -8
View File
@@ -7,7 +7,6 @@ package simplelog
import (
"context"
"encoding/json"
"errors"
"log"
"log/slog"
"os"
@@ -63,23 +62,20 @@ func NewMultiplexHandler() slog.Handler {
return cl
}
// Handle forwards the record to every underlying handler, including the
// ones after a handler that fails. It returns the failures joined with
// errors.Join, or nil when every handler succeeded.
// Handle forwards the record to every underlying handler, stopping at
// the first error.
func (cl *MultiplexHandler) Handle(
ctx context.Context,
record slog.Record,
) error {
var errs []error
for _, handler := range cl.handlers {
err := handler.Handle(ctx, record)
if err != nil {
errs = append(errs, err)
return err
}
}
return errors.Join(errs...)
return nil
}
// Enabled reports whether the handler processes records at the given
+3 -27
View File
@@ -4,22 +4,16 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
)
// errWebhookStatus is returned when the webhook answers with a status
// outside 2xx.
var errWebhookStatus = errors.New("webhook did not accept the record")
// WebhookHandler POSTs each log record as JSON to a configured webhook
// URL.
type WebhookHandler struct {
webhookURL string
client *http.Client
attrs handlerAttrs
}
@@ -31,17 +25,7 @@ func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
return nil, fmt.Errorf("invalid webhook URL: %w", err)
}
return &WebhookHandler{
webhookURL: webhookURL,
client: &http.Client{
// Following a redirect can resend the request as a GET
// without the record, so Handle gets the redirect answer
// itself and returns it as an error.
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}, nil
return &WebhookHandler{webhookURL: webhookURL}, nil
}
// Enabled reports whether the handler processes records at the given
@@ -59,7 +43,6 @@ func (w *WebhookHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &WebhookHandler{
webhookURL: w.webhookURL,
client: w.client,
attrs: w.attrs.withAttrs(attrs),
}
}
@@ -73,14 +56,12 @@ func (w *WebhookHandler) WithGroup(name string) slog.Handler {
return &WebhookHandler{
webhookURL: w.webhookURL,
client: w.client,
attrs: w.attrs.withGroup(name),
}
}
// Handle marshals the record, with its attributes, to one JSON object and
// POSTs it to the webhook URL. It returns an error when the request fails
// or the server answers with a status outside 2xx.
// 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 {
@@ -99,17 +80,12 @@ func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error {
request.Header.Set("Content-Type", "application/json")
response, err := w.client.Do(request)
response, err := http.DefaultClient.Do(request)
if err != nil {
return err
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode < http.StatusOK ||
response.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("%w: %s", errWebhookStatus, response.Status)
}
return nil
}