diff --git a/README.md b/README.md index 362a8c3..e1871cb 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: errors are ignored) + 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) { fmt.Fprintf(os.Stderr, "slog-betterstack: %v\n", err) }, +}.NewBetterstackHandler() +``` + Other global parameters: ```go diff --git a/handler.go b/handler.go index f72c271..e41d923 100644 --- a/handler.go +++ b/handler.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "encoding/json" + "fmt" + "io" "net/http" "time" @@ -14,17 +16,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 the error handler. +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: errors are ignored + OnError func(err error) + // optional: customize record builder Converter Converter // optional: custom marshaler @@ -54,6 +65,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 +107,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 +169,14 @@ 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 } + +// 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 new file mode 100644 index 0000000..4fa295b --- /dev/null +++ b/handler_test.go @@ -0,0 +1,146 @@ +package slogbetterstack + +import ( + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "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") + } +} + +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) + } +}