Skip to content

Commit 1c1f4b2

Browse files
authored
feat: report delivery failures through OnError (#32)
* 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. * docs: document the OnError option * feat: make the default OnError silent A logging library must not write to stderr unasked. Delivery failures stay observable by setting OnError. * docs: print delivery errors to stderr in the OnError example
1 parent ff77934 commit 1c1f4b2

3 files changed

Lines changed: 188 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ type Option struct {
107107
// default: 10s
108108
Timeout time.Duration
109109

110+
// optional: called when a record cannot be delivered
111+
// (default: errors are ignored)
112+
OnError func(err error)
113+
110114
// optional: customize record builder
111115
Converter Converter
112116
// optional: custom marshaler
@@ -120,6 +124,15 @@ type Option struct {
120124
}
121125
```
122126

127+
Delivery failures (marshaling error, network error, non-2xx response) are not returned by the logger. They are passed to `OnError`:
128+
129+
```go
130+
slogbetterstack.Option{
131+
Token: "xxxxx",
132+
OnError: func(err error) { fmt.Fprintf(os.Stderr, "slog-betterstack: %v\n", err) },
133+
}.NewBetterstackHandler()
134+
```
135+
123136
Other global parameters:
124137

125138
```go

‎handler.go‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"fmt"
8+
"io"
79
"net/http"
810
"time"
911

@@ -14,17 +16,26 @@ import (
1416

1517
const BetterstackEndpoint = "https://in.logs.betterstack.com/"
1618

19+
// maxErrorBodyBytes caps how much of a rejected response is quoted in an error,
20+
// so a large HTML error page cannot flood the error handler.
21+
const maxErrorBodyBytes = 512
22+
1723
type Option struct {
1824
// log level (default: debug)
1925
Level slog.Leveler
2026

2127
// token
22-
Token string
28+
Token string
2329
// optional: endpoint
2430
Endpoint string
2531
// default: 10s
2632
Timeout time.Duration
2733

34+
// optional: called when a record cannot be delivered (marshaling failure,
35+
// network error, non-2xx response).
36+
// default: errors are ignored
37+
OnError func(err error)
38+
2839
// optional: customize record builder
2940
Converter Converter
3041
// optional: custom marshaler
@@ -54,6 +65,10 @@ func (o Option) NewBetterstackHandler() slog.Handler {
5465
o.Timeout = 10 * time.Second
5566
}
5667

68+
if o.OnError == nil {
69+
o.OnError = defaultOnError
70+
}
71+
5772
if o.Converter == nil {
5873
o.Converter = DefaultConverter
5974
}
@@ -92,7 +107,10 @@ func (h *BetterstackHandler) Handle(ctx context.Context, record slog.Record) err
92107
// non-blocking
93108
go func() {
94109
// @TODO: batching ?
95-
_ = send(h.option.Endpoint, h.option.Token, h.option.Timeout, h.option.Marshaler, []map[string]any{payload})
110+
err := send(h.option.Endpoint, h.option.Token, h.option.Timeout, h.option.Marshaler, []map[string]any{payload})
111+
if err != nil {
112+
h.option.OnError(err)
113+
}
96114
}()
97115

98116
return nil
@@ -151,5 +169,14 @@ func send(endpoint string, token string, timeout time.Duration, marshaler func(v
151169

152170
defer resp.Body.Close() //nolint:errcheck
153171

172+
if resp.StatusCode < 200 || resp.StatusCode > 299 {
173+
// best effort: the status code alone is already actionable
174+
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
175+
return fmt.Errorf("betterstack rejected logs: status %d: %s", resp.StatusCode, bytes.TrimSpace(snippet))
176+
}
177+
154178
return nil
155179
}
180+
181+
// defaultOnError ignores errors: a logging library must not write to stderr unasked.
182+
func defaultOnError(error) {}

‎handler_test.go‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package slogbetterstack
2+
3+
import (
4+
"errors"
5+
"io"
6+
"log/slog"
7+
"net/http"
8+
"net/http/httptest"
9+
"os"
10+
"strings"
11+
"testing"
12+
"time"
13+
)
14+
15+
const testWait = 2 * time.Second // generous: delivery runs in a goroutine
16+
17+
func newTestHandler(t *testing.T, status int, body string, onError func(error)) slog.Handler {
18+
t.Helper()
19+
20+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
21+
w.WriteHeader(status)
22+
_, _ = w.Write([]byte(body))
23+
}))
24+
t.Cleanup(srv.Close)
25+
26+
return Option{Token: "t", Endpoint: srv.URL, OnError: onError}.NewBetterstackHandler()
27+
}
28+
29+
func TestOnError_RejectedStatus(t *testing.T) {
30+
for _, status := range []int{http.StatusUnauthorized, http.StatusInternalServerError, http.StatusMultipleChoices} {
31+
errs := make(chan error, 1)
32+
logger := slog.New(newTestHandler(t, status, "nope", func(err error) { errs <- err }))
33+
34+
logger.Info("hello")
35+
36+
select {
37+
case err := <-errs:
38+
if !strings.Contains(err.Error(), "nope") {
39+
t.Fatalf("status %d: unexpected error %q", status, err)
40+
}
41+
case <-time.After(testWait):
42+
t.Fatalf("status %d: OnError not called", status)
43+
}
44+
}
45+
}
46+
47+
func TestOnError_BodyIsTruncated(t *testing.T) {
48+
errs := make(chan error, 1)
49+
logger := slog.New(newTestHandler(t, http.StatusBadRequest, strings.Repeat("x", 10*maxErrorBodyBytes), func(err error) { errs <- err }))
50+
51+
logger.Info("hello")
52+
53+
select {
54+
case err := <-errs:
55+
if got := strings.Count(err.Error(), "x"); got != maxErrorBodyBytes {
56+
t.Fatalf("expected %d quoted bytes, got %d", maxErrorBodyBytes, got)
57+
}
58+
case <-time.After(testWait):
59+
t.Fatal("OnError not called")
60+
}
61+
}
62+
63+
func TestOnError_NotCalledOnSuccess(t *testing.T) {
64+
for _, status := range []int{http.StatusOK, http.StatusAccepted, 299} {
65+
called := make(chan struct{}, 1)
66+
done := make(chan struct{})
67+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
68+
w.WriteHeader(status)
69+
close(done)
70+
}))
71+
72+
logger := slog.New(Option{Token: "t", Endpoint: srv.URL, OnError: func(error) { called <- struct{}{} }}.NewBetterstackHandler())
73+
logger.Info("hello")
74+
75+
select {
76+
case <-done:
77+
case <-time.After(testWait):
78+
t.Fatalf("status %d: request not received", status)
79+
}
80+
time.Sleep(50 * time.Millisecond) // let the sender goroutine return
81+
srv.Close()
82+
83+
select {
84+
case <-called:
85+
t.Fatalf("status %d: OnError called on success", status)
86+
default:
87+
}
88+
}
89+
}
90+
91+
func TestOnError_NetworkAndMarshalFailure(t *testing.T) {
92+
errs := make(chan error, 2)
93+
94+
srv := httptest.NewServer(http.NotFoundHandler())
95+
endpoint := srv.URL
96+
srv.Close() // connection refused from here on
97+
98+
slog.New(Option{Token: "t", Endpoint: endpoint, OnError: func(err error) { errs <- err }}.NewBetterstackHandler()).Info("hello")
99+
100+
marshalErr := errors.New("boom")
101+
slog.New(Option{
102+
Token: "t",
103+
Endpoint: endpoint,
104+
Marshaler: func(any) ([]byte, error) { return nil, marshalErr },
105+
OnError: func(err error) { errs <- err },
106+
}.NewBetterstackHandler()).Info("hello")
107+
108+
sawMarshal := false
109+
for i := 0; i < 2; i++ {
110+
select {
111+
case err := <-errs:
112+
sawMarshal = sawMarshal || errors.Is(err, marshalErr)
113+
case <-time.After(testWait):
114+
t.Fatal("OnError not called")
115+
}
116+
}
117+
if !sawMarshal {
118+
t.Fatal("marshal error not reported")
119+
}
120+
}
121+
122+
func TestOnError_DefaultWritesNothing(t *testing.T) {
123+
r, w, err := os.Pipe()
124+
if err != nil {
125+
t.Fatal(err)
126+
}
127+
128+
stderr := os.Stderr
129+
os.Stderr = w
130+
defer func() { os.Stderr = stderr }()
131+
132+
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
133+
rw.WriteHeader(http.StatusUnauthorized)
134+
}))
135+
defer srv.Close()
136+
137+
h := Option{Token: "t", Endpoint: srv.URL}.NewBetterstackHandler()
138+
slog.New(h).Info("hello")
139+
time.Sleep(100 * time.Millisecond) // the failing send runs in a goroutine
140+
141+
_ = w.Close()
142+
out, _ := io.ReadAll(r)
143+
if len(out) != 0 {
144+
t.Fatalf("expected no output by default, got %q", out)
145+
}
146+
}

0 commit comments

Comments
 (0)