2 Commits
Author SHA1 Message Date
sneak 921eb5e542 Run the linter only in Docker (closes #20)
check / check (push) Successful in 58s
check / check (pull_request) Successful in 41s
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:53:21 +00:00
clawbot 151dd42b4b Return sink write errors from every handler (closes #22)
check / check (push) Successful in 35s
The console and JSON handlers threw away the error from their write to
stdout, so a lost log line looked delivered. They now return it,
wrapped. The webhook handler also returns an error for a status outside
2xx, and no longer follows redirects, which could resend the request
without the record. The multiplex handler passes the record to every
handler and returns their errors joined with errors.Join instead of
stopping at the first.

Both stdout handlers gain an unexported writer, nil meaning os.Stdout at
write time, so tests can supply a sink that fails. The README says what
each handler returns and that the slog.Logger methods discard a
handler's error.

Model: opus-5-5
2026-10-06 08:45:17 +02:00
13 changed files with 312 additions and 44 deletions
+2 -1
View File
@@ -6,7 +6,8 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN make fmt-check RUN make fmt-check
RUN make lint # Called directly: make lint is itself a docker build of this stage.
RUN golangci-lint run --config .golangci.yml ./...
# Test stage: run full test suite # Test stage: run full test suite
# golang 1.22.12 (2025-02-04) # golang 1.22.12 (2025-02-04)
+39 -9
View File
@@ -25,6 +25,9 @@ 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 in console output they are appended as `key=value` pairs, with grouped
keys written as `group.key=value`. See keys written as `group.key=value`. See
[Attribute output](#attribute-output) for the details worth knowing [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 ## Planned Features
@@ -104,6 +107,28 @@ 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 empty key is inlined into its parent, and `WithGroup("")` returns the
handler unchanged. 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 ## Entrypoints
This repository adheres to the This repository adheres to the
@@ -113,24 +138,29 @@ development workflow, and the Makefile targets are thin shims that call them.
The scripts are POSIX sh (not bash) so they run in minimal containers such as The scripts are POSIX sh (not bash) so they run in minimal containers such as
alpine. We provide: alpine. We provide:
- `script/bootstrap` — install all dependencies (go and golangci-lint if - `script/bootstrap` — install all dependencies (go if missing, then
missing, then `go mod download`) `go mod download`); golangci-lint is not installed, since it runs only in
Docker
- `script/setup` — set up the repo for development after a fresh clone: runs - `script/setup` — set up the repo for development after a fresh clone: runs
`script/bootstrap`, then `script/install-precommit` `script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (our own extension); used by - `script/projectname` — output the project name (our own extension); used by
`script/docker` for the image tag `script/docker` for the image tag
- `script/test` — run the test suite (`go test -v ./...`) - `script/test` — run the test suite (`go test -v ./...`)
- `script/lint` — run golangci-lint - `script/lint` — run golangci-lint in Docker by building only the `lint`
- `script/fmt` — format all files (goimports plus `golangci-lint run --fix`; stage of the `Dockerfile` (which also runs the format check), without the
writes) build cache, so every run lints; the image is tagged `simplelog-lint`
- `script/fmt` — format all files with goimports (writes)
- `script/fmt-check` — check formatting (read-only); fails if `gofmt -l` - `script/fmt-check` — check formatting (read-only); fails if `gofmt -l`
reports files reports files
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension) extension)
- `script/docker` — build the Docker image, tagged via `script/projectname` - `script/docker` — build the Docker image without the build cache, tagged
(byte-identical across repos) via `script/projectname` (byte-identical across repos); it also passes the
- `script/cibuild` — cd to the repo root and `docker build .` (what CI runs; output of `git describe` as the `VERSION` build argument, which is ignored
the image build runs the checks) because this `Dockerfile` does not declare it
- `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)
- `script/precommit` — run by the git pre-commit hook (our own extension); - `script/precommit` — run by the git pre-commit hook (our own extension);
runs a `go mod tidy` guard, then `script/check` runs a `go mod tidy` guard, then `script/check`
- `script/install-precommit` — installs the git pre-commit hook (our own - `script/install-precommit` — installs the git pre-commit hook (our own
+8 -3
View File
@@ -24,6 +24,14 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig,
# Completed Steps # Completed Steps
* 2026-10-06: the linter runs only in Docker: `script/lint` builds the
`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, * 2026-08-10: fixed every handler discarding slog attributes: console,
JSON and webhook handlers now emit record attributes, accumulate JSON and webhook handlers now emit record attributes, accumulate
WithAttrs without mutating the receiver, and honour WithGroup; WithAttrs without mutating the receiver, and honour WithGroup;
@@ -47,9 +55,6 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig,
# Future Steps # 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, * Restructure README.md into the standard sections: Description,
Getting Started, Rationale, Design, TODO, License, Author Getting Started, Rationale, Design, TODO, License, Author
* Delete the old plain TODO file once this TODO.md lands * Delete the old plain TODO file once this TODO.md lands
+17 -5
View File
@@ -3,6 +3,7 @@ package simplelog
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os" "os"
"runtime" "runtime"
@@ -17,6 +18,9 @@ const callerSkipFrames = 4
// ConsoleHandler writes human-readable, colored log lines to stdout. // ConsoleHandler writes human-readable, colored log lines to stdout.
type ConsoleHandler struct { 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 attrs handlerAttrs
} }
@@ -27,7 +31,7 @@ func NewConsoleHandler() *ConsoleHandler {
// Handle writes the record to stdout as a colored, timestamped line // Handle writes the record to stdout as a colored, timestamped line
// including the caller file and line, followed by the attributes as // including the caller file and line, followed by the attributes as
// key=value pairs. // key=value pairs. A failed write is returned as an error.
func (c *ConsoleHandler) Handle( func (c *ConsoleHandler) Handle(
_ context.Context, _ context.Context,
record slog.Record, record slog.Record,
@@ -56,8 +60,13 @@ func (c *ConsoleHandler) Handle(
line = 0 line = 0
} }
_, _ = fmt.Fprintln( out := c.out
os.Stdout, if out == nil {
out = os.Stdout
}
_, err := fmt.Fprintln(
out,
colorFunc( colorFunc(
"%s [%s] %s:%d: %s%s", "%s [%s] %s:%d: %s%s",
timestamp, timestamp,
@@ -68,6 +77,9 @@ func (c *ConsoleHandler) Handle(
attrsToText(c.attrs.forRecord(record)), attrsToText(c.attrs.forRecord(record)),
), ),
) )
if err != nil {
return fmt.Errorf("error writing log record: %w", err)
}
return nil return nil
} }
@@ -88,7 +100,7 @@ func (c *ConsoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return c return c
} }
return &ConsoleHandler{attrs: c.attrs.withAttrs(attrs)} return &ConsoleHandler{out: c.out, attrs: c.attrs.withAttrs(attrs)}
} }
// WithGroup returns a new handler that qualifies later attributes with // WithGroup returns a new handler that qualifies later attributes with
@@ -98,5 +110,5 @@ func (c *ConsoleHandler) WithGroup(name string) slog.Handler {
return c return c
} }
return &ConsoleHandler{attrs: c.attrs.withGroup(name)} return &ConsoleHandler{out: c.out, attrs: c.attrs.withGroup(name)}
} }
+162
View File
@@ -0,0 +1,162 @@
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)
}
}
+16 -4
View File
@@ -4,12 +4,16 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os" "os"
) )
// JSONHandler writes each log record to stdout as a JSON document. // JSONHandler writes each log record to stdout as a JSON document.
type JSONHandler struct { 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 attrs handlerAttrs
} }
@@ -19,14 +23,22 @@ func NewJSONHandler() *JSONHandler {
} }
// Handle marshals the record, with its attributes, to one JSON object and // Handle marshals the record, with its attributes, to one JSON object and
// writes it to stdout. // writes it to stdout. A failed write is returned as an error.
func (j *JSONHandler) Handle(_ context.Context, record slog.Record) error { func (j *JSONHandler) Handle(_ context.Context, record slog.Record) error {
jsonData, err := json.Marshal(recordToMap(record, j.attrs)) jsonData, err := json.Marshal(recordToMap(record, j.attrs))
if err != nil { if err != nil {
return err return err
} }
_, _ = fmt.Fprintln(os.Stdout, string(jsonData)) 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)
}
return nil return nil
} }
@@ -44,7 +56,7 @@ func (j *JSONHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return j return j
} }
return &JSONHandler{attrs: j.attrs.withAttrs(attrs)} return &JSONHandler{out: j.out, attrs: j.attrs.withAttrs(attrs)}
} }
// WithGroup returns a new handler that nests later attributes in an // WithGroup returns a new handler that nests later attributes in an
@@ -54,5 +66,5 @@ func (j *JSONHandler) WithGroup(name string) slog.Handler {
return j return j
} }
return &JSONHandler{attrs: j.attrs.withGroup(name)} return &JSONHandler{out: j.out, attrs: j.attrs.withGroup(name)}
} }
+1 -6
View File
@@ -54,12 +54,7 @@ main() {
if missing git; then pkg_install git git git git; fi if missing git; then pkg_install git git git git; fi
if missing go; then pkg_install go golang go go; fi if missing go; then pkg_install go golang go go; fi
# golangci-lint is packaged in nix, brew, and apk; there is no apt # golangci-lint is not installed: it runs only in docker (script/lint).
# package (on apt hosts, install it from a hash-verified GitHub
# release archive manually, never curl | sh).
if missing golangci-lint; then
pkg_install golangci-lint golangci-lint golangci-lint golangci-lint
fi
go mod download go mod download
+6 -4
View File
@@ -1,13 +1,15 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check, so # script/cibuild: run the CI build. The Dockerfile runs the format check,
# a successful build implies all checks pass. # the linter and the tests, so a successful build means they all pass.
# --no-cache because a cached check step is a check that did not run.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build . docker build --no-cache -t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"
+12 -1
View File
@@ -1,6 +1,8 @@
#!/bin/sh #!/bin/sh
# script/docker: build the Docker image tagged with the project name. # script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname. # 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.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -8,7 +10,16 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" . # 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")" .
} }
main "$@" main "$@"
-1
View File
@@ -7,7 +7,6 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
goimports -l -w . goimports -l -w .
golangci-lint run --fix
} }
main "$@" main "$@"
+14 -3
View File
@@ -1,12 +1,23 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. # 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.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
golangci-lint run docker build --no-cache \
--target lint \
-t "$("$SCRIPT_DIR/projectname")-lint" .
} }
main "$@" main "$@"
+8 -4
View File
@@ -7,6 +7,7 @@ package simplelog
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"log" "log"
"log/slog" "log/slog"
"os" "os"
@@ -62,20 +63,23 @@ func NewMultiplexHandler() slog.Handler {
return cl return cl
} }
// Handle forwards the record to every underlying handler, stopping at // Handle forwards the record to every underlying handler, including the
// the first error. // ones after a handler that fails. It returns the failures joined with
// errors.Join, or nil when every handler succeeded.
func (cl *MultiplexHandler) Handle( func (cl *MultiplexHandler) Handle(
ctx context.Context, ctx context.Context,
record slog.Record, record slog.Record,
) error { ) error {
var errs []error
for _, handler := range cl.handlers { for _, handler := range cl.handlers {
err := handler.Handle(ctx, record) err := handler.Handle(ctx, record)
if err != nil { if err != nil {
return err errs = append(errs, err)
} }
} }
return nil return errors.Join(errs...)
} }
// Enabled reports whether the handler processes records at the given // Enabled reports whether the handler processes records at the given
+27 -3
View File
@@ -4,16 +4,22 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url" "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 // WebhookHandler POSTs each log record as JSON to a configured webhook
// URL. // URL.
type WebhookHandler struct { type WebhookHandler struct {
webhookURL string webhookURL string
client *http.Client
attrs handlerAttrs attrs handlerAttrs
} }
@@ -25,7 +31,17 @@ func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
return nil, fmt.Errorf("invalid webhook URL: %w", err) return nil, fmt.Errorf("invalid webhook URL: %w", err)
} }
return &WebhookHandler{webhookURL: webhookURL}, nil 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
} }
// Enabled reports whether the handler processes records at the given // Enabled reports whether the handler processes records at the given
@@ -43,6 +59,7 @@ func (w *WebhookHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &WebhookHandler{ return &WebhookHandler{
webhookURL: w.webhookURL, webhookURL: w.webhookURL,
client: w.client,
attrs: w.attrs.withAttrs(attrs), attrs: w.attrs.withAttrs(attrs),
} }
} }
@@ -56,12 +73,14 @@ func (w *WebhookHandler) WithGroup(name string) slog.Handler {
return &WebhookHandler{ return &WebhookHandler{
webhookURL: w.webhookURL, webhookURL: w.webhookURL,
client: w.client,
attrs: w.attrs.withGroup(name), attrs: w.attrs.withGroup(name),
} }
} }
// Handle marshals the record, with its attributes, to one JSON object and // Handle marshals the record, with its attributes, to one JSON object and
// POSTs it to the webhook URL. // POSTs it to the webhook URL. It returns an error when the request fails
// or the server answers with a status outside 2xx.
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(recordToMap(record, w.attrs))
if err != nil { if err != nil {
@@ -80,12 +99,17 @@ func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error {
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(request) response, err := w.client.Do(request)
if err != nil { if err != nil {
return err return err
} }
defer func() { _ = response.Body.Close() }() defer func() { _ = response.Body.Close() }()
if response.StatusCode < http.StatusOK ||
response.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("%w: %s", errWebhookStatus, response.Status)
}
return nil return nil
} }