From 273e542c31c212110310374986e1fd396185e3ad Mon Sep 17 00:00:00 2001 From: Samuel Berthe Date: Tue, 13 Oct 2026 17:35:40 +0200 Subject: [PATCH 1/4] feat: report delivery failures through OnError Delivery errors and non-2xx responses were silently discarded, so a rejected token or a server error produced no output at all. Add an OnError option, called for marshaling failures, network errors and non-2xx responses. It defaults to one line on stderr. --- handler.go | 33 +++++++++++++- handler_test.go | 118 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 handler_test.go diff --git a/handler.go b/handler.go index f72c271..a2da26c 100644 --- a/handler.go +++ b/handler.go @@ -4,7 +4,10 @@ import ( "bytes" "context" "encoding/json" + "fmt" + "io" "net/http" + "os" "time" "log/slog" @@ -14,17 +17,26 @@ import ( const BetterstackEndpoint = "https://in.logs.betterstack.com/" +// maxErrorBodyBytes caps how much of a rejected response is quoted in an error, +// so a large HTML error page cannot flood stderr. +const maxErrorBodyBytes = 512 + type Option struct { // log level (default: debug) Level slog.Leveler // token - Token string + Token string // optional: endpoint Endpoint string // default: 10s Timeout time.Duration + // optional: called when a record cannot be delivered (marshaling failure, + // network error, non-2xx response). + // default: print one line on stderr + OnError func(err error) + // optional: customize record builder Converter Converter // optional: custom marshaler @@ -54,6 +66,10 @@ func (o Option) NewBetterstackHandler() slog.Handler { o.Timeout = 10 * time.Second } + if o.OnError == nil { + o.OnError = defaultOnError + } + if o.Converter == nil { o.Converter = DefaultConverter } @@ -92,7 +108,10 @@ func (h *BetterstackHandler) Handle(ctx context.Context, record slog.Record) err // non-blocking go func() { // @TODO: batching ? - _ = send(h.option.Endpoint, h.option.Token, h.option.Timeout, h.option.Marshaler, []map[string]any{payload}) + err := send(h.option.Endpoint, h.option.Token, h.option.Timeout, h.option.Marshaler, []map[string]any{payload}) + if err != nil { + h.option.OnError(err) + } }() return nil @@ -151,5 +170,15 @@ func send(endpoint string, token string, timeout time.Duration, marshaler func(v defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode < 200 || resp.StatusCode > 299 { + // best effort: the status code alone is already actionable + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes)) + return fmt.Errorf("betterstack rejected logs: status %d: %s", resp.StatusCode, bytes.TrimSpace(snippet)) + } + return nil } + +func defaultOnError(err error) { + fmt.Fprintf(os.Stderr, "slog-betterstack: %v\n", err) +} diff --git a/handler_test.go b/handler_test.go new file mode 100644 index 0000000..c91f922 --- /dev/null +++ b/handler_test.go @@ -0,0 +1,118 @@ +package slogbetterstack + +import ( + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +const testWait = 2 * time.Second // generous: delivery runs in a goroutine + +func newTestHandler(t *testing.T, status int, body string, onError func(error)) slog.Handler { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + + return Option{Token: "t", Endpoint: srv.URL, OnError: onError}.NewBetterstackHandler() +} + +func TestOnError_RejectedStatus(t *testing.T) { + for _, status := range []int{http.StatusUnauthorized, http.StatusInternalServerError, http.StatusMultipleChoices} { + errs := make(chan error, 1) + logger := slog.New(newTestHandler(t, status, "nope", func(err error) { errs <- err })) + + logger.Info("hello") + + select { + case err := <-errs: + if !strings.Contains(err.Error(), "nope") { + t.Fatalf("status %d: unexpected error %q", status, err) + } + case <-time.After(testWait): + t.Fatalf("status %d: OnError not called", status) + } + } +} + +func TestOnError_BodyIsTruncated(t *testing.T) { + errs := make(chan error, 1) + logger := slog.New(newTestHandler(t, http.StatusBadRequest, strings.Repeat("x", 10*maxErrorBodyBytes), func(err error) { errs <- err })) + + logger.Info("hello") + + select { + case err := <-errs: + if got := strings.Count(err.Error(), "x"); got != maxErrorBodyBytes { + t.Fatalf("expected %d quoted bytes, got %d", maxErrorBodyBytes, got) + } + case <-time.After(testWait): + t.Fatal("OnError not called") + } +} + +func TestOnError_NotCalledOnSuccess(t *testing.T) { + for _, status := range []int{http.StatusOK, http.StatusAccepted, 299} { + called := make(chan struct{}, 1) + done := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + close(done) + })) + + logger := slog.New(Option{Token: "t", Endpoint: srv.URL, OnError: func(error) { called <- struct{}{} }}.NewBetterstackHandler()) + logger.Info("hello") + + select { + case <-done: + case <-time.After(testWait): + t.Fatalf("status %d: request not received", status) + } + time.Sleep(50 * time.Millisecond) // let the sender goroutine return + srv.Close() + + select { + case <-called: + t.Fatalf("status %d: OnError called on success", status) + default: + } + } +} + +func TestOnError_NetworkAndMarshalFailure(t *testing.T) { + errs := make(chan error, 2) + + srv := httptest.NewServer(http.NotFoundHandler()) + endpoint := srv.URL + srv.Close() // connection refused from here on + + slog.New(Option{Token: "t", Endpoint: endpoint, OnError: func(err error) { errs <- err }}.NewBetterstackHandler()).Info("hello") + + marshalErr := errors.New("boom") + slog.New(Option{ + Token: "t", + Endpoint: endpoint, + Marshaler: func(any) ([]byte, error) { return nil, marshalErr }, + OnError: func(err error) { errs <- err }, + }.NewBetterstackHandler()).Info("hello") + + sawMarshal := false + for i := 0; i < 2; i++ { + select { + case err := <-errs: + sawMarshal = sawMarshal || errors.Is(err, marshalErr) + case <-time.After(testWait): + t.Fatal("OnError not called") + } + } + if !sawMarshal { + t.Fatal("marshal error not reported") + } +} From ecea83336c71caf56ceafde81b1d5657f05b83aa Mon Sep 17 00:00:00 2001 From: Samuel Berthe Date: Tue, 13 Oct 2026 17:51:22 +0200 Subject: [PATCH 2/4] docs: document the OnError option --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 362a8c3..9173dd5 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,10 @@ type Option struct { // default: 10s Timeout time.Duration + // optional: called when a record cannot be delivered + // (default: one line on stderr) + OnError func(err error) + // optional: customize record builder Converter Converter // optional: custom marshaler @@ -120,6 +124,15 @@ type Option struct { } ``` +Delivery failures (marshaling error, network error, non-2xx response) are not returned by the logger. They are passed to `OnError`: + +```go +slogbetterstack.Option{ + Token: "xxxxx", + OnError: func(err error) { myMetrics.Inc("betterstack_delivery_failed") }, +}.NewBetterstackHandler() +``` + Other global parameters: ```go From 0613970e04cb45a45eddf4c27c508a38230b600f Mon Sep 17 00:00:00 2001 From: Samuel Berthe Date: Tue, 13 Oct 2026 17:55:02 +0200 Subject: [PATCH 3/4] feat: make the default OnError silent A logging library must not write to stderr unasked. Delivery failures stay observable by setting OnError. --- README.md | 2 +- handler.go | 10 ++++------ handler_test.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9173dd5..eef250f 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ type Option struct { Timeout time.Duration // optional: called when a record cannot be delivered - // (default: one line on stderr) + // (default: errors are ignored) OnError func(err error) // optional: customize record builder diff --git a/handler.go b/handler.go index a2da26c..e41d923 100644 --- a/handler.go +++ b/handler.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "net/http" - "os" "time" "log/slog" @@ -18,7 +17,7 @@ import ( const BetterstackEndpoint = "https://in.logs.betterstack.com/" // maxErrorBodyBytes caps how much of a rejected response is quoted in an error, -// so a large HTML error page cannot flood stderr. +// so a large HTML error page cannot flood the error handler. const maxErrorBodyBytes = 512 type Option struct { @@ -34,7 +33,7 @@ type Option struct { // optional: called when a record cannot be delivered (marshaling failure, // network error, non-2xx response). - // default: print one line on stderr + // default: errors are ignored OnError func(err error) // optional: customize record builder @@ -179,6 +178,5 @@ func send(endpoint string, token string, timeout time.Duration, marshaler func(v return nil } -func defaultOnError(err error) { - fmt.Fprintf(os.Stderr, "slog-betterstack: %v\n", err) -} +// defaultOnError ignores errors: a logging library must not write to stderr unasked. +func defaultOnError(error) {} diff --git a/handler_test.go b/handler_test.go index c91f922..4fa295b 100644 --- a/handler_test.go +++ b/handler_test.go @@ -2,9 +2,11 @@ package slogbetterstack import ( "errors" + "io" "log/slog" "net/http" "net/http/httptest" + "os" "strings" "testing" "time" @@ -116,3 +118,29 @@ func TestOnError_NetworkAndMarshalFailure(t *testing.T) { t.Fatal("marshal error not reported") } } + +func TestOnError_DefaultWritesNothing(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + stderr := os.Stderr + os.Stderr = w + defer func() { os.Stderr = stderr }() + + srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + h := Option{Token: "t", Endpoint: srv.URL}.NewBetterstackHandler() + slog.New(h).Info("hello") + time.Sleep(100 * time.Millisecond) // the failing send runs in a goroutine + + _ = w.Close() + out, _ := io.ReadAll(r) + if len(out) != 0 { + t.Fatalf("expected no output by default, got %q", out) + } +} From 27c3b3916951bf86543752afe243130f9de9d6d1 Mon Sep 17 00:00:00 2001 From: Samuel Berthe Date: Tue, 13 Oct 2026 17:55:35 +0200 Subject: [PATCH 4/4] docs: print delivery errors to stderr in the OnError example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index eef250f..e1871cb 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ Delivery failures (marshaling error, network error, non-2xx response) are not re ```go slogbetterstack.Option{ Token: "xxxxx", - OnError: func(err error) { myMetrics.Inc("betterstack_delivery_failed") }, + OnError: func(err error) { fmt.Fprintf(os.Stderr, "slog-betterstack: %v\n", err) }, }.NewBetterstackHandler() ```