Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
31 changes: 29 additions & 2 deletions handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"

Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {}
146 changes: 146 additions & 0 deletions handler_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading