diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f22a928..5f07650 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,19 +10,24 @@ on: branches: [ "master" ] jobs: - build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go: [stable, oldstable] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: '1.20' + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + check-latest: true + cache: true - - name: Build - run: go build -v ./... + - name: Build + run: go build -v ./... - - name: Test - run: go test -v ./... + - name: Test + run: go test -race -v ./... diff --git a/Makefile b/Makefile index 4409d54..d6faaab 100644 --- a/Makefile +++ b/Makefile @@ -3,9 +3,7 @@ all: test prepare: # needed for `make fmt` go get golang.org/x/tools/cmd/goimports - # linters - go get github.com/alecthomas/gometalinter - gometalinter --install + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest # needed for `make cover` go get golang.org/x/tools/cmd/cover @echo Now you should be ready to run "make" @@ -18,7 +16,7 @@ fmt: find . -name "*.go" -exec goimports -w {} \; lint: - gometalinter + golangci-lint run cover: go test -cover -coverprofile cover.out diff --git a/README.md b/README.md index 4497064..5956d95 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,14 @@ Don't forget to destroy the Heroku app after you're done so that you aren't char The code for the sample app is [available on Github](https://github.com/honeybadger-io/crywolf-go), in case you'd like to read through it, or run it locally. +## Supported Go Versions + +This library supports the last two major Go releases, consistent with the Go team's [release policy](https://go.dev/doc/devel/release): + +- Go 1.25.x +- Go 1.24.x + +Older versions may work but are not officially supported or tested. ## Configuration @@ -99,18 +107,22 @@ honeybadger.Configure(honeybadger.Configuration{ ``` The following options are available to you: -| Name | Type | Default | Example | Environment variable | -| ----- | ---- | ------- | ------- | -------------------- | +| Name | Type | Default | Example | Environment variable | +|------|------|----------|----------|----------------------| | APIKey | `string` | `""` | `"badger01"` | `HONEYBADGER_API_KEY` | | Root | `string` | The current working directory | `"/path/to/project"` | `HONEYBADGER_ROOT` | | Env | `string` | `""` | `"production"` | `HONEYBADGER_ENV` | -| Hostname | `string` | The hostname of the current server. | `"badger01"` | `HONEYBADGER_HOSTNAME` | +| Hostname | `string` | The hostname of current server | `"badger01"` | `HONEYBADGER_HOSTNAME` | | Endpoint | `string` | `"https://api.honeybadger.io"` | `"https://honeybadger.example.com/"` | `HONEYBADGER_ENDPOINT` | -| Sync | `bool` | false | `true` | `HONEYBADGER_SYNC` | +| Sync | `bool` | `false` | `true` | `HONEYBADGER_SYNC` | | Timeout | `time.Duration` | 3 seconds | `10 * time.Second` | `HONEYBADGER_TIMEOUT` (nanoseconds) | | Logger | `honeybadger.Logger` | Logs to stderr | `CustomLogger{}` | n/a | | Backend | `honeybadger.Backend` | HTTP backend | `CustomBackend{}` | n/a | - +| EventsBatchSize | `int` | 1000 | `500` | `HONEYBADGER_EVENTS_BATCH_SIZE` | +| EventsTimeout | `time.Duration` | 30 seconds | `10 * time.Second` | `HONEYBADGER_EVENTS_TIMEOUT` (nanoseconds) | +| EventsMaxQueueSize | `int` | 100000 | `50000` | `HONEYBADGER_EVENTS_MAX_QUEUE_SIZE` | +| EventsMaxRetries | `int` | 3 | `5` | `HONEYBADGER_EVENTS_MAX_RETRIES` | +| EventsThrottleWait | `time.Duration` | 60 seconds | `30 * time.Second` | `HONEYBADGER_EVENTS_THROTTLE_WAIT` (nanoseconds)| ## Public Interface @@ -228,6 +240,56 @@ honeybadger.BeforeNotify( --- +### `honeybadger.Event()`: Send events to Honeybadger Insights. + +Send custom events to Honeybadger Insights for tracking application behavior and metrics. + +#### Examples: + +```go +honeybadger.Event("user_login", map[string]any{ + "user_id": 123, + "email": "user@example.com", +}) +``` + +Events are batched and sent asynchronously. Configuration options are available +for batching, retries, and throttling. See [Configuration](#configuration) for details. + +--- + +### `honeybadger.BeforeEvent()`: Add a callback to skip or modify event data. + +Similar to `BeforeNotify()`, you can add callbacks to modify event data or skip events entirely before they are sent. + +#### Examples: + +To modify or augment event data: + +```go +honeybadger.BeforeEvent( + func(event map[string]any) error { + event["environment"] = "production" + return nil + } +) +``` + +To skip events, use `honeybadger.ErrEventDropped`: + +```go +honeybadger.BeforeEvent( + func(event map[string]any) error { + if event["event_type"] == "debug_event" { + return honeybadger.ErrEventDropped + } + return nil + } +) +``` + +--- + ### ``honeybadger.NewNullBackend()``: Disable data reporting. `NewNullBackend` creates a backend which swallows all errors and does not send them to Honeybadger. This is useful for development and testing to disable sending unnecessary errors. diff --git a/client.go b/client.go index e1c247f..09bdc29 100644 --- a/client.go +++ b/client.go @@ -1,6 +1,7 @@ package honeybadger import ( + "errors" "net/http" ) @@ -14,9 +15,13 @@ type Payload interface { // custom implementation may be configured by the user. type Backend interface { Notify(feature Feature, payload Payload) error + Event(events []*eventPayload) error } +var ErrEventDropped = errors.New("event dropped by handler") + type noticeHandler func(*Notice) error +type eventHandler func(map[string]any) error // Client is the manager for interacting with the Honeybadger service. It holds // the configuration and implements the public API. @@ -25,11 +30,22 @@ type Client struct { context *contextSync worker worker beforeNotifyHandlers []noticeHandler + eventsWorker *EventsWorker + beforeEventHandlers []eventHandler +} + +func eventsConfigChanged(config *Configuration) bool { + return config.EventsBatchSize > 0 || config.EventsTimeout > 0 || config.EventsMaxQueueSize > 0 || config.EventsMaxRetries >= 0 || config.Backend != nil || config.Context != nil } // Configure updates the client configuration with the supplied config. func (client *Client) Configure(config Configuration) { client.Config.update(&config) + + if eventsConfigChanged(&config) && client.eventsWorker != nil { + client.eventsWorker.Stop() + client.eventsWorker = NewEventsWorker(client.Config) + } } // SetContext updates the client context with supplied context. @@ -40,6 +56,7 @@ func (client *Client) SetContext(context Context) { // Flush blocks until the worker has processed its queue. func (client *Client) Flush() { client.worker.Flush() + client.eventsWorker.Flush() } // BeforeNotify adds a callback function which is run before a notice is @@ -49,6 +66,10 @@ func (client *Client) BeforeNotify(handler func(notice *Notice) error) { client.beforeNotifyHandlers = append(client.beforeNotifyHandlers, handler) } +func (client *Client) BeforeEvent(handler func(event map[string]any) error) { + client.beforeEventHandlers = append(client.beforeEventHandlers, handler) +} + // Notify reports the error err to the Honeybadger service. func (client *Client) Notify(err interface{}, extra ...interface{}) (string, error) { extra = append([]interface{}{client.context.internal}, extra...) @@ -78,6 +99,27 @@ func (client *Client) Notify(err interface{}, extra ...interface{}) (string, err return notice.Token, nil } +func (client *Client) Event(eventType string, eventData map[string]interface{}) error { + event := newEventPayload(eventType, eventData) + + for _, handler := range client.beforeEventHandlers { + err := handler(event.data) + + if err == ErrEventDropped { + return nil + } else if err != nil { + return err + } + } + + if client.Config.Sync { + return client.Config.Backend.Event([]*eventPayload{event}) + } + + client.eventsWorker.Push(event) + return nil +} + // Monitor automatically reports panics which occur in the function it's called // from. Must be deferred. func (client *Client) Monitor() { @@ -110,11 +152,13 @@ func (client *Client) Handler(h http.Handler) http.Handler { func New(c Configuration) *Client { config := newConfig(c) worker := newBufferedWorker(config) + eventsWorker := NewEventsWorker(config) client := Client{ - Config: config, - worker: worker, - context: newContextSync(), + Config: config, + worker: worker, + context: newContextSync(), + eventsWorker: eventsWorker, } return &client diff --git a/client_test.go b/client_test.go index 2646c7b..9effbce 100644 --- a/client_test.go +++ b/client_test.go @@ -114,6 +114,10 @@ func (b *mockBackend) Notify(_ Feature, n Payload) error { return nil } +func (b *mockBackend) Event(events []*eventPayload) error { + return nil +} + func mockClient(c Configuration) (Client, *mockWorker, *mockBackend) { worker := &mockWorker{} backend := &mockBackend{} diff --git a/configuration.go b/configuration.go index 0e07a1d..6fbc6cc 100644 --- a/configuration.go +++ b/configuration.go @@ -1,6 +1,7 @@ package honeybadger import ( + "context" "log" "os" "strconv" @@ -15,15 +16,21 @@ type Logger interface { // Configuration manages the configuration for the client. type Configuration struct { - APIKey string - Root string - Env string - Hostname string - Endpoint string - Sync bool - Timeout time.Duration - Logger Logger - Backend Backend + APIKey string + Root string + Env string + Hostname string + Endpoint string + Sync bool + Timeout time.Duration + Logger Logger + Backend Backend + Context context.Context + EventsBatchSize int + EventsThrottleWait time.Duration + EventsTimeout time.Duration + EventsMaxQueueSize int + EventsMaxRetries int } func (c1 *Configuration) update(c2 *Configuration) *Configuration { @@ -51,6 +58,24 @@ func (c1 *Configuration) update(c2 *Configuration) *Configuration { if c2.Backend != nil { c1.Backend = c2.Backend } + if c2.Context != nil { + c1.Context = c2.Context + } + if c2.EventsBatchSize > 0 { + c1.EventsBatchSize = c2.EventsBatchSize + } + if c2.EventsTimeout > 0 { + c1.EventsTimeout = c2.EventsTimeout + } + if c2.EventsMaxQueueSize > 0 { + c1.EventsMaxQueueSize = c2.EventsMaxQueueSize + } + if c2.EventsMaxRetries > 0 { + c1.EventsMaxRetries = c2.EventsMaxRetries + } + if c2.EventsThrottleWait > 0 { + c1.EventsThrottleWait = c2.EventsThrottleWait + } c1.Sync = c2.Sync return c1 @@ -58,14 +83,30 @@ func (c1 *Configuration) update(c2 *Configuration) *Configuration { func newConfig(c Configuration) *Configuration { config := &Configuration{ - APIKey: getEnv("HONEYBADGER_API_KEY"), - Root: getPWD(), - Env: getEnv("HONEYBADGER_ENV"), - Hostname: getHostname(), - Endpoint: getEnv("HONEYBADGER_ENDPOINT", "https://api.honeybadger.io"), - Timeout: getTimeout(), - Logger: log.New(os.Stderr, "[honeybadger] ", log.Flags()), - Sync: getSync(), + APIKey: GetEnv[string]("HONEYBADGER_API_KEY"), + Root: GetEnv[string]("HONEYBADGER_ROOT", func() string { + if val, err := os.Getwd(); err == nil { + return val + } + return "" + }), + Env: GetEnv[string]("HONEYBADGER_ENV"), + Hostname: GetEnv[string]("HONEYBADGER_HOSTNAME", func() string { + if val, err := os.Hostname(); err == nil { + return val + } + return "" + }), + Endpoint: GetEnv[string]("HONEYBADGER_ENDPOINT", "https://api.honeybadger.io"), + Timeout: GetEnv[time.Duration]("HONEYBADGER_TIMEOUT", 3*time.Second), + Logger: log.New(os.Stderr, "[honeybadger] ", log.Flags()), + Sync: GetEnv[bool]("HONEYBADGER_SYNC", false), + Context: context.Background(), + EventsThrottleWait: GetEnv[time.Duration]("HONEYBADGER_EVENTS_THROTTLE_WAIT", 60*time.Second), + EventsBatchSize: GetEnv[int]("HONEYBADGER_EVENTS_BATCH_SIZE", 1000), + EventsTimeout: GetEnv[time.Duration]("HONEYBADGER_EVENTS_TIMEOUT", 30*time.Second), + EventsMaxQueueSize: GetEnv[int]("HONEYBADGER_EVENTS_MAX_QUEUE_SIZE", 100000), + EventsMaxRetries: GetEnv[int]("HONEYBADGER_EVENTS_MAX_RETRIES", 3), } config.update(&c) @@ -76,40 +117,52 @@ func newConfig(c Configuration) *Configuration { return config } -func getTimeout() time.Duration { - if env := getEnv("HONEYBADGER_TIMEOUT"); env != "" { - if ns, err := strconv.ParseInt(env, 10, 64); err == nil { - return time.Duration(ns) +func GetEnv[T any](key string, fallback ...any) T { + val := os.Getenv(key) + if val == "" { + if len(fallback) > 0 { + switch f := fallback[0].(type) { + case func() T: + return f() + case T: + return f + } } + var zero T + return zero } - return 3 * time.Second -} - -func getEnv(key string, fallback ...string) (val string) { - val = os.Getenv(key) - if val == "" && len(fallback) > 0 { - return fallback[0] - } - return -} -func getHostname() (hostname string) { - if val, err := os.Hostname(); err == nil { - hostname = val + switch any((*new(T))).(type) { + case int: + if v, err := strconv.Atoi(val); err == nil { + return any(v).(T) + } + case float64: + if v, err := strconv.ParseFloat(val, 64); err == nil { + return any(v).(T) + } + case bool: + return any(true).(T) + case time.Duration: + if v, err := strconv.ParseInt(val, 10, 64); err == nil { + return any(time.Duration(v)).(T) + } + if v, err := time.ParseDuration(val); err == nil { + return any(v).(T) + } + case string: + return any(val).(T) } - return getEnv("HONEYBADGER_HOSTNAME", hostname) -} -func getPWD() (pwd string) { - if val, err := os.Getwd(); err == nil { - pwd = val + if len(fallback) > 0 { + switch f := fallback[0].(type) { + case func() T: + return f() + case T: + return f + } } - return getEnv("HONEYBADGER_ROOT", pwd) -} -func getSync() bool { - if getEnv("HONEYBADGER_SYNC") != "" { - return true - } - return false + var zero T + return zero } diff --git a/configuration_test.go b/configuration_test.go index e3870fb..8c98a7e 100644 --- a/configuration_test.go +++ b/configuration_test.go @@ -1,17 +1,14 @@ package honeybadger -import "testing" +import ( + "testing" + "time" +) type TestLogger struct{} func (l *TestLogger) Printf(format string, v ...interface{}) {} -type TestBackend struct{} - -func (l *TestBackend) Notify(f Feature, p Payload) (err error) { - return -} - func TestUpdateConfig(t *testing.T) { config := &Configuration{} logger := &TestLogger{} @@ -41,3 +38,106 @@ func TestReplaceConfigPointer(t *testing.T) { t.Errorf("Expected updated config to update pointer expected=%#v actual=%#v", "/tmp/bar", *root) } } + +func TestGetEnv_BasicTypesAndFallbacks(t *testing.T) { + t.Run("string with env", func(t *testing.T) { + t.Setenv("X", "abc") + got := GetEnv[string]("X") + if got != "abc" { + t.Fatalf("want abc, got %q", got) + } + }) + + t.Run("string with value fallback", func(t *testing.T) { + t.Setenv("X", "") + got := GetEnv[string]("X", "def") + if got != "def" { + t.Fatalf("want def, got %q", got) + } + }) + + t.Run("string with func fallback (lazy)", func(t *testing.T) { + t.Setenv("X", "") + calls := 0 + got := GetEnv[string]("X", func() string { calls++; return "zzz" }) + if calls != 1 || got != "zzz" { + t.Fatalf("fallback not called once or wrong value: calls=%d got=%q", calls, got) + } + }) + + t.Run("string with func fallback NOT called if env present", func(t *testing.T) { + t.Setenv("X", "live") + called := false + got := GetEnv[string]("X", func() string { called = true; return "nope" }) + if called { + t.Fatal("fallback was called unexpectedly") + } + if got != "live" { + t.Fatalf("want live, got %q", got) + } + }) + + t.Run("int parse ok", func(t *testing.T) { + t.Setenv("X", "42") + got := GetEnv[int]("X") + if got != 42 { + t.Fatalf("want 42, got %d", got) + } + }) + + t.Run("int parse bad -> value fallback", func(t *testing.T) { + t.Setenv("X", "nope") + got := GetEnv[int]("X", 7) + if got != 7 { + t.Fatalf("want fallback 7, got %d", got) + } + }) + + t.Run("float64 parse ok", func(t *testing.T) { + t.Setenv("X", "3.14") + got := GetEnv[float64]("X") + if got != 3.14 { + t.Fatalf("want 3.14, got %v", got) + } + }) + + t.Run("bool parse ok", func(t *testing.T) { + t.Setenv("X", "true") + got := GetEnv[bool]("X") + if !got { + t.Fatalf("want true, got false") + } + + got = GetEnv[bool]("Y") + if got { + t.Fatalf("want false, got true") + } + }) + + t.Run("duration parse ok", func(t *testing.T) { + t.Setenv("X", "120000000") + got := GetEnv[time.Duration]("X") + if got != 120*time.Millisecond { + t.Fatalf("want 120ms, got %v", got) + } + + t.Setenv("Y", "150ms") + got = GetEnv[time.Duration]("Y") + if got != 150*time.Millisecond { + t.Fatalf("want 150ms, got %v", got) + } + }) + + t.Run("zero value when missing and no fallback", func(t *testing.T) { + t.Setenv("X", "") + if got := GetEnv[int]("X"); got != 0 { + t.Fatalf("want 0, got %d", got) + } + if got := GetEnv[string]("X"); got != "" { + t.Fatalf("want empty string, got %q", got) + } + if got := GetEnv[bool]("X"); got != false { + t.Fatalf("want false, got %v", got) + } + }) +} diff --git a/event.go b/event.go new file mode 100644 index 0000000..63beb62 --- /dev/null +++ b/event.go @@ -0,0 +1,27 @@ +package honeybadger + +import ( + "maps" + "time" +) + +type eventPayload struct { + data map[string]any +} + +func (e *eventPayload) toJSON() []byte { + h := hash(e.data) + return h.toJSON() +} + +func newEventPayload(eventType string, eventData map[string]any) *eventPayload { + data := make(map[string]any) + maps.Copy(data, eventData) + + data["event_type"] = eventType + if _, ok := data["ts"]; !ok { + data["ts"] = time.Now().UTC().Format(time.RFC3339) + } + + return &eventPayload{data: data} +} diff --git a/events_worker.go b/events_worker.go new file mode 100644 index 0000000..7b782ce --- /dev/null +++ b/events_worker.go @@ -0,0 +1,173 @@ +package honeybadger + +import ( + "context" + "sync" + "sync/atomic" + "time" +) + +type Batch struct { + events []*eventPayload + attempts int +} + +type EventsWorker struct { + backend Backend + batchSize int + throttleWait time.Duration + timeout time.Duration + maxQueueSize int + maxRetries int + logger Logger + + ticker *time.Ticker + queue *ringBuffer + queueSize int + batches []*Batch + throttling atomic.Bool + + in chan *eventPayload + flushCh chan struct{} + shutdownCh chan struct{} + + wg sync.WaitGroup + once sync.Once +} + +func NewEventsWorker(cfg *Configuration) *EventsWorker { + ctx := cfg.Context + if ctx == nil { + ctx = context.Background() + } + + w := &EventsWorker{ + backend: cfg.Backend, + batchSize: cfg.EventsBatchSize, + timeout: cfg.EventsTimeout, + maxQueueSize: cfg.EventsMaxQueueSize, + maxRetries: cfg.EventsMaxRetries, + throttleWait: cfg.EventsThrottleWait, + logger: cfg.Logger, + // +1 so we can push before checking flush threshold without dropping an event. + queue: newRingBuffer(cfg.EventsBatchSize + 1), + queueSize: 0, + batches: make([]*Batch, 0), + in: make(chan *eventPayload), + flushCh: make(chan struct{}, 1), + shutdownCh: make(chan struct{}), + } + w.wg.Add(1) + go w.run(ctx) + return w +} + +func (w *EventsWorker) Push(e *eventPayload) { + if w.throttling.Load() { + return + } + + w.in <- e +} + +func (w *EventsWorker) Flush() { + select { + case w.flushCh <- struct{}{}: + default: + } +} + +func (w *EventsWorker) Stop() { + w.once.Do(func() { + close(w.shutdownCh) + w.wg.Wait() + }) +} + +func (w *EventsWorker) AttemptSend() bool { + events := w.queue.drain() + if len(events) > 0 { + w.batches = append(w.batches, &Batch{events: events, attempts: 0}) + } + w.queue = newRingBuffer(w.batchSize + 1) + + for len(w.batches) > 0 { + batch := w.batches[0] + if batch.attempts > w.maxRetries { + w.logger.Printf("events worker dropping batch after %d failed attempts\n", batch.attempts) + w.batches = w.batches[1:] + w.queueSize -= len(batch.events) + continue + } + + err := w.backend.Event(batch.events) + + if err == ErrRateExceeded { + w.throttling.Store(true) + w.ticker.Reset(w.throttleWait) + } else if err != nil { + batch.attempts++ + w.logger.Printf("events worker send error: %v\n", err) + break + } else { + w.throttling.Store(false) + w.batches = w.batches[1:] + w.queueSize -= len(batch.events) + } + } + + return len(w.batches) > 0 +} + +func (w *EventsWorker) run(ctx context.Context) { + defer w.wg.Done() + + w.ticker = time.NewTicker(w.timeout) + defer w.ticker.Stop() + + flush := func() { + if w.queue.len() == 0 && len(w.batches) == 0 { + return + } + + hasPendingBatches := w.AttemptSend() + if hasPendingBatches { + w.ticker.Reset(w.timeout) + } + } + + for { + select { + case <-ctx.Done(): + flush() + return + + case <-w.shutdownCh: + flush() + return + + case <-w.ticker.C: + flush() + + case <-w.flushCh: + flush() + + case e := <-w.in: + w.queue.push(e) + + if w.queueSize >= w.maxQueueSize { + // Drop oldest if at capacity + if w.queue.len() > 0 { + w.queue.pop() + } + } else { + w.queueSize++ + } + + if w.queue.len() >= w.batchSize { + flush() + w.ticker.Reset(w.timeout) + } + } + } +} diff --git a/go.mod b/go.mod index 0fc7b88..743967b 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,27 @@ module github.com/honeybadger-io/honeybadger-go -go 1.12 +go 1.24.0 + +toolchain go1.24.7 require ( github.com/pborman/uuid v1.2.1 github.com/shirou/gopsutil v3.21.11+incompatible - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect - golang.org/x/sys v0.1.0 // indirect + golang.org/x/mod v0.28.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053 // indirect + golang.org/x/tools v0.37.0 // indirect + golang.org/x/tools/cmd/cover v0.1.0-deprecated // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6152727..43a9328 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,3 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= @@ -11,21 +10,26 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053 h1:dHQOQddU4YHS5gY33/6klKjq7Gp3WwMyOXGNp5nzRj8= +golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools/cmd/cover v0.1.0-deprecated h1:Rwy+mWYz6loAF+LnG1jHG/JWMHRMMC2/1XX3Ejkx9lA= +golang.org/x/tools/cmd/cover v0.1.0-deprecated/go.mod h1:hMDiIvlpN1NoVgmjLjUJE9tMHyxHjFX7RuQ+rW12mSA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/honeybadger.go b/honeybadger.go index 7c5ebde..b919d92 100644 --- a/honeybadger.go +++ b/honeybadger.go @@ -18,6 +18,9 @@ var ( // Notices is the feature for sending error reports. Notices = Feature{"notices"} + + // Events is the feature for sending events to Insights. + Events = Feature{"events"} ) // Feature references a resource provided by the API service. Its Endpoint maps @@ -70,6 +73,10 @@ func Notify(err interface{}, extra ...interface{}) (string, error) { return DefaultClient.Notify(newError(err, 2), extra...) } +func Event(eventType string, eventData map[string]interface{}) error { + return DefaultClient.Event(eventType, eventData) +} + // Monitor is used to automatically notify Honeybadger service of panics which // happen inside the current function. In order to monitor for panics, defer a // call to Monitor. For example: @@ -105,3 +112,10 @@ func Handler(h http.Handler) http.Handler { func BeforeNotify(handler func(notice *Notice) error) { DefaultClient.BeforeNotify(handler) } + +// BeforeEvent adds a callback function which is run before an event is +// sent to Honeybadger. If any function returns an error the event +// will be dropped, otherwise it will be sent. +func BeforeEvent(handler func(event map[string]any) error) { + DefaultClient.BeforeEvent(handler) +} diff --git a/honeybadger_test.go b/honeybadger_test.go index e1a2d12..1b736e7 100644 --- a/honeybadger_test.go +++ b/honeybadger_test.go @@ -1,20 +1,28 @@ package honeybadger import ( + "context" "encoding/json" "errors" "fmt" - "io/ioutil" + "io" "net/http" "net/http/httptest" "net/url" "reflect" + "strings" "testing" + "time" "github.com/pborman/uuid" "github.com/stretchr/testify/mock" ) +type eventReqResp struct { + Body []byte + Response chan int +} + var ( mux *http.ServeMux ts *httptest.Server @@ -45,10 +53,16 @@ func (h *HTTPRequest) decodeJSON() hash { } func newHTTPRequest(r *http.Request) *HTTPRequest { - body, _ := ioutil.ReadAll(r.Body) + body, _ := io.ReadAll(r.Body) return &HTTPRequest{r, body} } +func assertMethod(t *testing.T, r *http.Request, method string) { + if r.Method != method { + t.Errorf("Unexpected request method. actual=%#v expected=%#v", r.Method, method) + } +} + func setup(t *testing.T) { mux = http.NewServeMux() ts = httptest.NewServer(mux) @@ -65,8 +79,47 @@ func setup(t *testing.T) { *DefaultClient.Config = *newConfig(Configuration{APIKey: "badgers", Endpoint: ts.URL}) } +func setupEvents(t *testing.T) chan eventReqResp { + mux = http.NewServeMux() + ts = httptest.NewServer(mux) + control := make(chan eventReqResp) + + mux.HandleFunc("/v1/events", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, r, "POST") + body, _ := io.ReadAll(r.Body) + respCh := make(chan int) + + select { + case control <- eventReqResp{Body: body, Response: respCh}: + status := <-respCh + w.WriteHeader(status) + if status == 201 { + fmt.Fprint(w, `{"id":"event-id"}`) + } + default: + w.WriteHeader(201) + fmt.Fprint(w, `{"id":"event-id"}`) + } + }) + + if DefaultClient.eventsWorker != nil { + DefaultClient.eventsWorker.Stop() + } + + config := newConfig(Configuration{APIKey: "badgers", Endpoint: ts.URL}) + *DefaultClient.Config = *config + DefaultClient.eventsWorker = NewEventsWorker(config) + + return control +} + func teardown() { + if DefaultClient.eventsWorker != nil { + DefaultClient.eventsWorker.Stop() + } *DefaultClient.Config = defaultConfig + DefaultClient.beforeNotifyHandlers = nil + DefaultClient.beforeEventHandlers = nil } func TestDefaultConfig(t *testing.T) { @@ -133,7 +186,7 @@ func TestNotifyWithErrorClass(t *testing.T) { } payload := requests[0].decodeJSON() - error_payload, _ := payload["error"].(map[string]interface{}) + error_payload, _ := payload["error"].(map[string]any) sent_klass, _ := error_payload["class"].(string) if !testNoticePayload(t, payload) { @@ -158,14 +211,14 @@ func TestNotifyWithTags(t *testing.T) { } payload := requests[0].decodeJSON() - error_payload, _ := payload["error"].(map[string]interface{}) - sent_tags, _ := error_payload["tags"].([]interface{}) + error_payload, _ := payload["error"].(map[string]any) + sent_tags, _ := error_payload["tags"].([]any) if !testNoticePayload(t, payload) { return } - if got, want := sent_tags, []interface{}{"timeout", "http"}; !reflect.DeepEqual(got, want) { + if got, want := sent_tags, []any{"timeout", "http"}; !reflect.DeepEqual(got, want) { t.Errorf("Custom error class should override default. expected=%#v actual=%#v.", want, got) return } @@ -183,7 +236,7 @@ func TestNotifyWithFingerprint(t *testing.T) { } payload := requests[0].decodeJSON() - error_payload, _ := payload["error"].(map[string]interface{}) + error_payload, _ := payload["error"].(map[string]any) sent_fingerprint, _ := error_payload["fingerprint"].(string) if !testNoticePayload(t, payload) { @@ -229,34 +282,34 @@ func TestNotifyWithRequest(t *testing.T) { // Request[1] - Checks URL & query extraction payload := requests[1].decodeJSON() - request_payload, _ := payload["request"].(map[string]interface{}) + request_payload, _ := payload["request"].(map[string]any) if url, _ := request_payload["url"].(string); url != reqUrl { t.Errorf("Request URL should be extracted. expected=%v actual=%#v.", "/fail", url) return } - params, _ := request_payload["params"].(map[string]interface{}) - values, _ := params["qKey"].([]interface{}) + params, _ := request_payload["params"].(map[string]any) + values, _ := params["qKey"].([]any) if len(params) != 1 || len(values) != 1 || values[0] != "qValue" { t.Errorf("Request params should be extracted. expected=%v actual=%#v.", req.Form, params) } // Request[2] - Checks header & form extraction payload = requests[2].decodeJSON() - request_payload, _ = payload["request"].(map[string]interface{}) + request_payload, _ = payload["request"].(map[string]any) if !testNoticePayload(t, payload) { return } - cgi, _ := request_payload["cgi_data"].(map[string]interface{}) + cgi, _ := request_payload["cgi_data"].(map[string]any) if len(cgi) != 1 || cgi["HTTP_ACCEPT"] != "application/test-data" { t.Errorf("Request cgi_data should be extracted. expected=%v actual=%#v.", req.Header, cgi) } - params, _ = request_payload["params"].(map[string]interface{}) - values, _ = params["fKey"].([]interface{}) + params, _ = request_payload["params"].(map[string]any) + values, _ = params["fKey"].([]any) if len(params) != 1 || len(values) != 1 || values[0] != "fValue" { t.Errorf("Request params should be extracted. expected=%v actual=%#v.", req.Form, params) } @@ -293,7 +346,7 @@ func TestNotifyWithHandler(t *testing.T) { Flush() payload := requests[0].decodeJSON() - error_payload, _ := payload["error"].(map[string]interface{}) + error_payload, _ := payload["error"].(map[string]any) sent_fingerprint, _ := error_payload["fingerprint"].(string) if !testRequestCount(t, 1) { @@ -334,13 +387,13 @@ func assertContext(t *testing.T, payload hash, expected Context) { var request, context hash var ok bool - request, ok = payload["request"].(map[string]interface{}) + request, ok = payload["request"].(map[string]any) if !ok { t.Errorf("Missing request in payload actual=%#v.", payload) return } - context, ok = request["context"].(map[string]interface{}) + context, ok = request["context"].(map[string]any) if !ok { t.Errorf("Missing context in request payload actual=%#v.", request) return @@ -365,7 +418,7 @@ func testRequestCount(t *testing.T, num int) bool { func testNoticePayload(t *testing.T, payload hash) bool { for _, key := range []string{"notifier", "error", "request", "server"} { switch payload[key].(type) { - case map[string]interface{}: + case map[string]any: // OK default: t.Errorf("Expected payload to include %v hash. expected=%#v actual=%#v", key, key, payload) @@ -375,6 +428,412 @@ func testNoticePayload(t *testing.T, payload hash) bool { return true } +func TestEvent(t *testing.T) { + control := setupEvents(t) + defer teardown() + + Configure(Configuration{EventsBatchSize: 1}) + + eventData := map[string]any{ + "message": "test message", + "user_id": 123, + } + + err := Event("test_event", eventData) + if err != nil { + t.Errorf("Expected Event() to return no error. actual=%#v", err) + } + + req := <-control + req.Response <- 201 + + lines := strings.Split(strings.TrimSpace(string(req.Body)), "\n") + if len(lines) != 1 { + t.Fatalf("Expected 1 JSONL event. actual=%d lines", len(lines)) + } + + var event map[string]any + if err := json.Unmarshal([]byte(lines[0]), &event); err != nil { + t.Fatalf("Failed to parse JSONL event: %v", err) + } + if eventType, ok := event["event_type"].(string); !ok || eventType != "test_event" { + t.Errorf("Expected event_type 'test_event'. actual=%#v", event["event_type"]) + } + if message, ok := event["message"].(string); !ok || message != "test message" { + t.Errorf("Expected message 'test message'. actual=%#v", event["message"]) + } + if _, ok := event["ts"].(string); !ok { + t.Errorf("Expected ts field to be present. actual=%#v", event) + } +} + +func TestEventBatching(t *testing.T) { + control := setupEvents(t) + defer teardown() + + Configure(Configuration{EventsBatchSize: 2}) + + Event("event1", map[string]any{"data": "first"}) + + select { + case <-control: + t.Errorf("Expected no requests before batch size reached") + case <-time.After(50 * time.Millisecond): + } + + Event("event2", map[string]any{"data": "second"}) + + select { + case req := <-control: + req.Response <- 201 + case <-time.After(100 * time.Millisecond): + t.Fatalf("Expected 1 batch request when batch size reached") + } +} + +func TestEventTimeout(t *testing.T) { + control := setupEvents(t) + defer teardown() + + Configure(Configuration{EventsBatchSize: 10, EventsTimeout: 50 * time.Millisecond}) + + Event("event1", map[string]any{"data": "first"}) + + select { + case <-control: + t.Errorf("Expected no immediate requests") + case <-time.After(25 * time.Millisecond): + } + + select { + case req := <-control: + req.Response <- 201 + case <-time.After(100 * time.Millisecond): + t.Fatalf("Expected 1 request after timeout") + } +} + +func TestEventContextCancellation(t *testing.T) { + control := setupEvents(t) + defer teardown() + + ctx, cancel := context.WithCancel(context.Background()) + Configure(Configuration{EventsBatchSize: 10, Context: ctx}) + + Event("test_event", map[string]any{"data": "should be flushed"}) + + select { + case <-control: + t.Errorf("Expected no requests before context cancellation") + case <-time.After(50 * time.Millisecond): + } + + cancel() + + select { + case req := <-control: + req.Response <- 201 + lines := strings.Split(strings.TrimSpace(string(req.Body)), "\n") + var event map[string]any + json.Unmarshal([]byte(lines[0]), &event) + + if event["data"] != "should be flushed" { + t.Errorf("Expected event data 'should be flushed'. actual=%v", event["data"]) + } + case <-time.After(100 * time.Millisecond): + t.Fatalf("Expected 1 request after context cancellation") + } +} + +func parseEvents(body string) []map[string]any { + lines := strings.Split(strings.TrimSpace(body), "\n") + events := make([]map[string]any, len(lines)) + + for i, line := range lines { + json.Unmarshal([]byte(line), &events[i]) + } + + return events +} + +func TestEventMaxQueueSize(t *testing.T) { + control := setupEvents(t) + defer teardown() + + Configure(Configuration{EventsBatchSize: 10, EventsMaxQueueSize: 2}) + + Event("old_event", map[string]any{"data": "should be dropped"}) + Event("middle_event", map[string]any{"data": "middle"}) + Event("new_event", map[string]any{"data": "newest"}) + + DefaultClient.eventsWorker.Flush() + + select { + case req := <-control: + req.Response <- 201 + events := parseEvents(string(req.Body)) + + if len(events) != 2 { + t.Fatalf("Expected 2 events. actual=%d", len(events)) + } + + expectedData := []string{"middle", "newest"} + for i, expected := range expectedData { + if events[i]["data"] != expected { + t.Errorf("Expected event %d data '%s'. actual=%v", i, expected, events[i]["data"]) + } + } + case <-time.After(100 * time.Millisecond): + t.Fatalf("Expected 1 request") + } +} + +func TestEventFailureRecovery(t *testing.T) { + control := setupEvents(t) + defer teardown() + + Configure(Configuration{ + EventsBatchSize: 2, + EventsMaxQueueSize: 5, + EventsMaxRetries: 3, + EventsTimeout: 50 * time.Millisecond, + }) + + Event("1", map[string]any{"data": "first"}) + Event("2", map[string]any{"data": "second"}) + + req1 := <-control + t.Log("First attempt", string(req1.Body)) + req1.Response <- 500 + + req2 := <-control + t.Log("Second attempt", string(req2.Body)) + req2.Response <- 500 + + req3 := <-control + t.Log("Third attempt", string(req3.Body)) + req3.Response <- 201 +} + +func TestEventQueueSizeIncludesPendingBatches(t *testing.T) { + control := setupEvents(t) + defer teardown() + + Configure(Configuration{ + EventsBatchSize: 2, + EventsMaxQueueSize: 3, + EventsMaxRetries: 3, + }) + + Event("1", map[string]any{"data": "1"}) + Event("2", map[string]any{"data": "2"}) + + req1 := <-control + req1.Response <- 500 + + Event("3", map[string]any{"data": "3"}) + Event("4", map[string]any{"data": "4"}) + + time.Sleep(50 * time.Millisecond) + + DefaultClient.eventsWorker.Flush() + + req2 := <-control + req2.Response <- 201 + + req3 := <-control + events := parseEvents(string(req3.Body)) + + if len(events) != 1 { + t.Errorf("Expected 1 event after dropping. actual=%d", len(events)) + } + if events[0]["data"] != "4" { + t.Errorf("Expected newest event '4'. actual=%v", events[0]["data"]) + } + req3.Response <- 201 +} + +func TestEventThrottling(t *testing.T) { + control := setupEvents(t) + defer teardown() + + Configure(Configuration{ + EventsBatchSize: 2, + EventsMaxRetries: 2, + EventsThrottleWait: 50 * time.Millisecond, + }) + + Event("1", map[string]any{"data": "1"}) + Event("2", map[string]any{"data": "2"}) + + req1 := <-control + req1.Response <- 429 + + Event("3", map[string]any{"data": "3"}) + Event("4", map[string]any{"data": "4"}) + + req2 := <-control + req2.Response <- 429 + + req3 := <-control + req3.Response <- 201 + + select { + case <-control: + t.Errorf("Expected no more batches, events 3 and 4 should have been dropped during throttling") + case <-time.After(100 * time.Millisecond): + } +} + +func TestEventMultipleBatchRetryOrdering(t *testing.T) { + control := setupEvents(t) + defer teardown() + + Configure(Configuration{ + EventsBatchSize: 2, + EventsMaxRetries: 3, + EventsTimeout: 100 * time.Millisecond, + }) + + Event("1", map[string]any{"data": "1"}) + Event("2", map[string]any{"data": "2"}) + + req1 := <-control + req1.Response <- 500 + + Event("3", map[string]any{"data": "3"}) + Event("4", map[string]any{"data": "4"}) + + req2 := <-control + events := parseEvents(string(req2.Body)) + if events[0]["data"] != "1" || events[1]["data"] != "2" { + t.Errorf("Expected first batch to retry. actual=%v", events) + } + req2.Response <- 500 + + req3 := <-control + events = parseEvents(string(req3.Body)) + if events[0]["data"] != "1" || events[1]["data"] != "2" { + t.Errorf("Expected first batch to retry again. actual=%v", events) + } + req3.Response <- 201 + + req4 := <-control + events = parseEvents(string(req4.Body)) + if len(events) != 2 { + t.Fatalf("Expected second batch. actual=%d events", len(events)) + } + if events[0]["data"] != "3" || events[1]["data"] != "4" { + t.Errorf("Expected second batch after first succeeds. actual=%v", events) + } + req4.Response <- 201 +} + +func TestEventShutdownWithPendingRetries(t *testing.T) { + control := setupEvents(t) + defer teardown() + + ctx, cancel := context.WithCancel(context.Background()) + Configure(Configuration{ + EventsBatchSize: 2, + EventsMaxRetries: 3, + Context: ctx, + }) + + Event("1", map[string]any{"data": "1"}) + Event("2", map[string]any{"data": "2"}) + + req1 := <-control + req1.Response <- 500 + + cancel() + + req2 := <-control + events := parseEvents(string(req2.Body)) + if len(events) != 2 { + t.Fatalf("Expected 2 events in retry batch on shutdown. actual=%d", len(events)) + } + if events[0]["data"] != "1" || events[1]["data"] != "2" { + t.Errorf("Expected failed batch to be flushed on shutdown. actual=%v", events) + } + req2.Response <- 201 +} + +func TestEventWithHandler(t *testing.T) { + control := setupEvents(t) + defer teardown() + + Configure(Configuration{EventsBatchSize: 1}) + + BeforeEvent(func(event map[string]any) error { + event["modified"] = true + return nil + }) + + Event("test_event", map[string]any{"data": "original"}) + + req := <-control + req.Response <- 201 + + lines := strings.Split(strings.TrimSpace(string(req.Body)), "\n") + var event map[string]any + json.Unmarshal([]byte(lines[0]), &event) + + if event["modified"] != true { + t.Errorf("Expected handler to modify event. actual=%v", event) + } + if event["data"] != "original" { + t.Errorf("Expected original data to be preserved. actual=%v", event["data"]) + } +} + +func TestEventWithHandlerError(t *testing.T) { + control := setupEvents(t) + defer teardown() + + Configure(Configuration{EventsBatchSize: 1}) + + err := fmt.Errorf("skip this event") + BeforeEvent(func(event map[string]any) error { + return err + }) + + eventErr := Event("test_event", map[string]any{"data": "test"}) + + if eventErr != err { + t.Errorf("Expected Event to return handler error. actual=%v", eventErr) + } + + select { + case <-control: + t.Errorf("Expected no event to be sent when handler returns error") + case <-time.After(100 * time.Millisecond): + } +} + +func TestEventWithHandlerDropped(t *testing.T) { + control := setupEvents(t) + defer teardown() + + Configure(Configuration{EventsBatchSize: 1}) + + BeforeEvent(func(event map[string]any) error { + return ErrEventDropped + }) + + eventErr := Event("test_event", map[string]any{"data": "test"}) + + if eventErr != nil { + t.Errorf("Expected Event to return nil when ErrEventDropped. actual=%v", eventErr) + } + + select { + case <-control: + t.Errorf("Expected no event to be sent when handler returns ErrEventDropped") + case <-time.After(100 * time.Millisecond): + } +} + func TestHandlerCallsHandler(t *testing.T) { mockHandler := &MockedHandler{} mockHandler.On("ServeHTTP").Return() @@ -386,9 +845,3 @@ func TestHandlerCallsHandler(t *testing.T) { mockHandler.AssertCalled(t, "ServeHTTP") } - -func assertMethod(t *testing.T, r *http.Request, method string) { - if r.Method != method { - t.Errorf("Unexpected request method. actual=%#v expected=%#v", r.Method, method) - } -} diff --git a/null_backend.go b/null_backend.go index d2452bc..e38b95f 100644 --- a/null_backend.go +++ b/null_backend.go @@ -18,3 +18,8 @@ func NewNullBackend() Backend { func (*nullBackend) Notify(_ Feature, _ Payload) error { return nil } + +// Event swallows events, does nothing, and returns no error. +func (*nullBackend) Event(_ []*eventPayload) error { + return nil +} diff --git a/ring_buffer.go b/ring_buffer.go new file mode 100644 index 0000000..a958cc7 --- /dev/null +++ b/ring_buffer.go @@ -0,0 +1,57 @@ +package honeybadger + +type ringBuffer struct { + buf []*eventPayload + head, tail int // head: next pop, tail: next push + size int + cap int +} + +func newRingBuffer(limit int) *ringBuffer { + return &ringBuffer{buf: make([]*eventPayload, limit), cap: limit} +} + +func (q *ringBuffer) push(it *eventPayload) bool { + if q.size == q.cap { // full + return false + } + q.buf[q.tail] = it + q.tail = (q.tail + 1) % q.cap + q.size++ + return true +} + +func (q *ringBuffer) pop() *eventPayload { + if q.size == 0 { + return nil + } + it := q.buf[q.head] + q.buf[q.head] = nil + q.head = (q.head + 1) % q.cap + q.size-- + return it +} + +func (q *ringBuffer) drain() []*eventPayload { + if q.size == 0 { + return nil + } + + out := make([]*eventPayload, q.size) + if q.head < q.tail { + copy(out, q.buf[q.head:q.tail]) + } else { + n := copy(out, q.buf[q.head:]) + copy(out[n:], q.buf[:q.tail]) + } + + for i := range q.buf { + q.buf[i] = nil + } + + q.head, q.tail, q.size = 0, 0, 0 + + return out +} + +func (q *ringBuffer) len() int { return q.size } diff --git a/server.go b/server.go index 9eb77bf..563ecbb 100644 --- a/server.go +++ b/server.go @@ -4,7 +4,7 @@ import ( "bytes" "errors" "fmt" - "io/ioutil" + "io" "net/http" "net/url" "time" @@ -37,22 +37,34 @@ type server struct { } func (s *server) Notify(feature Feature, payload Payload) error { - // Copy the value from the pointer in case it has changed in the - // configuration. + return s.sendRequest("v1/"+feature.Endpoint, payload.toJSON(), "application/json") +} + +func (s *server) Event(events []*eventPayload) error { + var jsonl []byte + for _, event := range events { + jsonl = append(jsonl, event.toJSON()...) + jsonl = append(jsonl, '\n') + } + return s.sendRequest("v1/events", jsonl, "application/x-ndjson") +} + +func (s *server) sendRequest(path string, body []byte, contentType string) error { s.Client.Timeout = *s.Timeout url, err := url.Parse(*s.URL) if err != nil { return err } - url.Path = "v1/" + feature.Endpoint - req, err := http.NewRequest("POST", url.String(), bytes.NewReader(payload.toJSON())) + url.Path = path + + req, err := http.NewRequest("POST", url.String(), bytes.NewReader(body)) if err != nil { return err } req.Header.Set("X-API-Key", *s.APIKey) - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", contentType) req.Header.Set("Accept", "application/json") resp, err := s.Client.Do(req) @@ -60,12 +72,12 @@ func (s *server) Notify(feature Feature, payload Payload) error { return err } defer func() { - ioutil.ReadAll(resp.Body) + io.ReadAll(resp.Body) resp.Body.Close() }() switch resp.StatusCode { - case 201: + case 200, 201: return nil case 429, 503: return ErrRateExceeded @@ -74,9 +86,12 @@ func (s *server) Notify(feature Feature, payload Payload) error { case 403: return ErrUnauthorized default: + bodyBytes, _ := io.ReadAll(resp.Body) return fmt.Errorf( - "request failed status=%d expected=%d", + "request failed status=%d expected=%d message=%q", resp.StatusCode, - http.StatusCreated) + http.StatusCreated, + string(bodyBytes), + ) } } diff --git a/slog/README.md b/slog/README.md new file mode 100644 index 0000000..7d1ac0c --- /dev/null +++ b/slog/README.md @@ -0,0 +1,144 @@ +# slog-honeybadger + +A Honeybadger handler for Go's slog package. +Send structured logs directly to Honeybadger as events. + +## Features + +- Structured logs as Honeybadger events +- Supports attributes, groups, and custom event types +- Chainable `With*` methods +- Log-level filtering with static or dynamic levels + +## Install + +```bash +go get github.com/honeybadger-io/honeybadger-go +``` + +**Requires Go 1.21+** + +## Quick start + +```go +import ( + "log/slog" + "github.com/honeybadger-io/honeybadger-go" + hbslog "github.com/honeybadger-io/honeybadger-go/slog" +) + +func main() { + client := honeybadger.New(honeybadger.Configuration{ + APIKey: "your-api-key", + }) + logger := slog.New(hbslog.New(client)) + logger.Info("app started", "version", "1.0.0") +} +``` + +**Produces:** + +```json +{ + "event_type": "log", + "level": "INFO", + "message": "app started", + "version": "1.0.0" +} +``` + +## Event types + +The default event type is `log`. You can set a custom event type for all +logs using `WithEventType`: + +```go +audit := slog.New(hbslog.New(client).WithEventType("audit")) +audit.Info("user logged in", "user_id", 42) +``` + +**Produces:** + +```json +{ + "event_type": "audit", + "level": "INFO", + "message": "user logged in", + "user_id": 42 +} +``` + +Set event type per log call with the `event_type` attribute: + +```go +logger := slog.New(hbslog.New(client)) + +logger.Info("user signup", "event_type", "user_lifecycle", "user_id", 123) +logger.Info("payment processed", "event_type", "payment", "amount", 99.99) +logger.Info("regular log message", "foo", "bar") +``` + +**Produces three events with different types:** + +```json +{"event_type": "user_lifecycle", "level": "INFO", "message": "user signup", "user_id": 123} +{"event_type": "payment", "level": "INFO", "message": "payment processed", "amount": 99.99} +{"event_type": "log", "level": "INFO", "message": "regular log message", "foo": "bar"} +``` + +## Attributes and groups + +```go +handler := hbslog.New(client). + WithAttrs([]slog.Attr{slog.String("service", "api")}). + WithGroup("http") + +logger := slog.New(handler) +logger.Info("request handled", "status", 200, "method", "POST") +``` + +**Produces:** + +```json +{ + "event_type": "log", + "level": "INFO", + "message": "request handled", + "service": "api", + "http": { + "status": 200, + "method": "POST" + } +} +``` + +## Log level filtering + +Control which logs are sent to Honeybadger: + +```go +// Only send WARN and above +handler := hbslog.New(client).WithLevel(slog.LevelWarn) +logger := slog.New(handler) + +logger.Info("This is ignored") +logger.Warn("This is sent") +logger.Error("This is sent") +``` + +## Dynamic level changes + +```go +levelVar := new(slog.LevelVar) +levelVar.Set(slog.LevelInfo) + +handler := hbslog.New(client).WithLevel(levelVar) +logger := slog.New(handler) + +// Change level at runtime +levelVar.Set(slog.LevelDebug) // Now debug logs will be sent +``` + +## License + +MIT © Honeybadger.io diff --git a/slog/handler.go b/slog/handler.go new file mode 100644 index 0000000..02367b4 --- /dev/null +++ b/slog/handler.go @@ -0,0 +1,150 @@ +package hbslog + +import ( + "context" + "log/slog" + + "github.com/honeybadger-io/honeybadger-go" +) + +type preformattedAttr struct { + groups []string + attr slog.Attr +} + +type Handler struct { + c *honeybadger.Client + eventType string + preformat []preformattedAttr + groups []string + level slog.Leveler +} + +func New(c *honeybadger.Client) *Handler { + return &Handler{ + c: c, + eventType: "log", + level: slog.LevelInfo, + } +} + +func (h *Handler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.level.Level() +} + +func (h *Handler) Handle(_ context.Context, r slog.Record) error { + data := map[string]any{ + "level": r.Level.String(), + "message": r.Message, + } + + for _, pf := range h.preformat { + h.appendAttr(data, pf.groups, pf.attr) + } + + eventType := h.eventType + r.Attrs(func(a slog.Attr) bool { + if a.Key == "event_type" { + if et, ok := a.Value.Any().(string); ok { + eventType = et + return true + } + } + h.appendAttr(data, h.groups, a) + return true + }) + + return h.c.Event(eventType, data) +} + +func (h *Handler) appendAttr(data map[string]any, groups []string, attr slog.Attr) { + value := attr.Value.Resolve() + + if value.Kind() == slog.KindGroup { + groupAttrs := value.Group() + if len(groupAttrs) == 0 { + return + } + + nestedGroups := append(groups, attr.Key) + for _, groupAttr := range groupAttrs { + h.appendAttr(data, nestedGroups, groupAttr) + } + return + } + + if len(groups) == 0 { + data[attr.Key] = value.Any() + return + } + + current := data + for _, group := range groups { + if _, ok := current[group]; !ok { + current[group] = make(map[string]any) + } + current = current[group].(map[string]any) + } + current[attr.Key] = value.Any() +} + +func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { + newPreformat := make([]preformattedAttr, len(h.preformat), len(h.preformat)+len(attrs)) + copy(newPreformat, h.preformat) + + for _, attr := range attrs { + groupsCopy := make([]string, len(h.groups)) + copy(groupsCopy, h.groups) + newPreformat = append(newPreformat, preformattedAttr{ + groups: groupsCopy, + attr: attr, + }) + } + + return &Handler{ + c: h.c, + eventType: h.eventType, + preformat: newPreformat, + groups: h.groups, + level: h.level, + } +} + +func (h *Handler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + newGroups := make([]string, 0, len(h.groups)+1) + newGroups = append(newGroups, h.groups...) + newGroups = append(newGroups, name) + return &Handler{ + c: h.c, + eventType: h.eventType, + preformat: h.preformat, + groups: newGroups, + level: h.level, + } +} + +func (h *Handler) WithEventType(eventType string) *Handler { + if eventType == "" { + eventType = "log" + } + return &Handler{ + c: h.c, + eventType: eventType, + preformat: h.preformat, + groups: h.groups, + level: h.level, + } +} + +func (h *Handler) WithLevel(level slog.Leveler) *Handler { + return &Handler{ + c: h.c, + eventType: h.eventType, + preformat: h.preformat, + groups: h.groups, + level: level, + } +} diff --git a/slog/handler_test.go b/slog/handler_test.go new file mode 100644 index 0000000..19dd68c --- /dev/null +++ b/slog/handler_test.go @@ -0,0 +1,464 @@ +package hbslog + +import ( + "log/slog" + "testing" + + "github.com/honeybadger-io/honeybadger-go" +) + +func newTestClient() (*honeybadger.Client, *honeybadger.TestBackend) { + backend := &honeybadger.TestBackend{} + client := honeybadger.New(honeybadger.Configuration{ + APIKey: "test-key", + Backend: backend, + Sync: true, + }) + return client, backend +} + +func TestWithAttrs(t *testing.T) { + client, backend := newTestClient() + handler := New(client).WithEventType("test") + + baseAttrs := []slog.Attr{ + slog.String("service", "api"), + slog.Int("version", 1), + } + loggerWithAttrs := slog.New(handler.WithAttrs(baseAttrs)) + + loggerWithAttrs.Info("test message", "request_id", "123") + + events := backend.GetEvents() + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + + event := events[0] + if event.EventType != "test" { + t.Errorf("expected event type 'test', got %q", event.EventType) + } + + if event.Data["level"] != "INFO" { + t.Errorf("expected level='INFO', got %v", event.Data["level"]) + } + if event.Data["message"] != "test message" { + t.Errorf("expected message='test message', got %v", event.Data["message"]) + } + if event.Data["service"] != "api" { + t.Errorf("expected service='api', got %v", event.Data["service"]) + } + if event.Data["version"] != int64(1) { + t.Errorf("expected version=1, got %v (type %T)", event.Data["version"], event.Data["version"]) + } + if event.Data["request_id"] != "123" { + t.Errorf("expected request_id='123', got %v", event.Data["request_id"]) + } +} + +func TestWithAttrsChaining(t *testing.T) { + client, backend := newTestClient() + handler := New(client).WithEventType("test") + + handler1 := handler.WithAttrs([]slog.Attr{slog.String("service", "api")}) + handler2 := handler1.WithAttrs([]slog.Attr{slog.Int("version", 2)}) + + logger := slog.New(handler2) + logger.Info("chained message") + + events := backend.GetEvents() + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + + event := events[0] + if event.Data["service"] != "api" { + t.Errorf("expected service='api', got %v", event.Data["service"]) + } + if event.Data["version"] != int64(2) { + t.Errorf("expected version=2, got %v", event.Data["version"]) + } +} + +func TestWithGroup(t *testing.T) { + client, backend := newTestClient() + handler := New(client).WithEventType("test") + + loggerWithGroup := slog.New(handler.WithGroup("request")) + loggerWithGroup.Info("test message", "id", "123", "method", "GET") + + events := backend.GetEvents() + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + + event := events[0] + if event.Data["level"] != "INFO" { + t.Errorf("expected level='INFO', got %v", event.Data["level"]) + } + if event.Data["message"] != "test message" { + t.Errorf("expected message='test message', got %v", event.Data["message"]) + } + + requestGroup, ok := event.Data["request"].(map[string]any) + if !ok { + t.Fatalf("expected 'request' to be a map, got %T", event.Data["request"]) + } + + if requestGroup["id"] != "123" { + t.Errorf("expected request.id='123', got %v", requestGroup["id"]) + } + if requestGroup["method"] != "GET" { + t.Errorf("expected request.method='GET', got %v", requestGroup["method"]) + } +} + +func TestWithGroupNested(t *testing.T) { + client, backend := newTestClient() + handler := New(client).WithEventType("test") + + handler1 := handler.WithGroup("request") + handler2 := handler1.WithGroup("headers") + + logger := slog.New(handler2) + logger.Info("nested group", "content-type", "application/json") + + events := backend.GetEvents() + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + + event := events[0] + requestGroup, ok := event.Data["request"].(map[string]any) + if !ok { + t.Fatalf("expected 'request' to be a map, got %T", event.Data["request"]) + } + + headersGroup, ok := requestGroup["headers"].(map[string]any) + if !ok { + t.Fatalf("expected 'request.headers' to be a map, got %T", requestGroup["headers"]) + } + + if headersGroup["content-type"] != "application/json" { + t.Errorf("expected content-type='application/json', got %v", headersGroup["content-type"]) + } +} + +func TestWithAttrsAndGroup(t *testing.T) { + client, backend := newTestClient() + handler := New(client).WithEventType("test") + + handler1 := handler.WithAttrs([]slog.Attr{slog.String("service", "api")}) + handler2 := handler1.WithGroup("request") + + logger := slog.New(handler2) + logger.Info("mixed", "id", "456") + + events := backend.GetEvents() + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + + event := events[0] + if event.Data["service"] != "api" { + t.Errorf("expected service='api', got %v", event.Data["service"]) + } + + requestGroup, ok := event.Data["request"].(map[string]any) + if !ok { + t.Fatalf("expected 'request' to be a map, got %T", event.Data["request"]) + } + + if requestGroup["id"] != "456" { + t.Errorf("expected request.id='456', got %v", requestGroup["id"]) + } +} + +func TestWithAttrsBeforeAndAfterGroup(t *testing.T) { + client, backend := newTestClient() + h := New(client).WithEventType("test") + + h1 := h.WithAttrs([]slog.Attr{slog.String("service", "api")}) + h2 := h1.WithGroup("http").WithAttrs([]slog.Attr{slog.String("method", "GET")}) + + slog.New(h2).Info("x") + e := backend.GetEvents()[0] + + if got := e.Data["service"]; got != "api" { + t.Fatalf("top-level pre-attr lost: %v", got) + } + httpM := e.Data["http"].(map[string]any) + if httpM["method"] != "GET" { + t.Fatalf("grouped pre-attr missing: %v", httpM["method"]) + } +} + +func TestWithEventType(t *testing.T) { + client, backend := newTestClient() + handler := New(client) + + handler2 := handler.WithEventType("audit") + logger := slog.New(handler2) + logger.Info("audit event", "user_id", "123") + + events := backend.GetEvents() + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + + event := events[0] + if event.EventType != "audit" { + t.Errorf("expected event type 'audit', got %q", event.EventType) + } + if event.Data["user_id"] != "123" { + t.Errorf("expected user_id='123', got %v", event.Data["user_id"]) + } +} + +func TestWithEventTypePreservesAttrsAndGroups(t *testing.T) { + client, backend := newTestClient() + handler := New(client) + + handler2 := handler.WithEventType("api_call"). + WithAttrs([]slog.Attr{slog.String("env", "prod")}). + WithGroup("request") + + logger := slog.New(handler2) + logger.Info("api call", "path", "/users") + + events := backend.GetEvents() + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + + event := events[0] + if event.EventType != "api_call" { + t.Errorf("expected event type 'api_call', got %q", event.EventType) + } + if event.Data["env"] != "prod" { + t.Errorf("expected env='prod', got %v", event.Data["env"]) + } + + requestGroup, ok := event.Data["request"].(map[string]any) + if !ok { + t.Fatalf("expected 'request' to be a map, got %T", event.Data["request"]) + } + if requestGroup["path"] != "/users" { + t.Errorf("expected request.path='/users', got %v", requestGroup["path"]) + } +} + +func TestInlineSlogGroup(t *testing.T) { + client, backend := newTestClient() + h := New(client).WithEventType("test") + slog.New(h.WithGroup("http")).Info("x", + slog.Group("resp", slog.Int("status", 200), slog.String("ct", "json")), + ) + e := backend.GetEvents()[0] + http := e.Data["http"].(map[string]any) + resp := http["resp"].(map[string]any) + if resp["status"] != int64(200) || resp["ct"] != "json" { + t.Fatalf("inline group not expanded: %#v", resp) + } +} + +type counterValuer struct { + v *int +} + +func (c counterValuer) LogValue() slog.Value { + return slog.IntValue(*c.v) +} + +func TestLogValuerResolvesAtHandleTime(t *testing.T) { + client, backend := newTestClient() + h := New(client).WithEventType("test") + + v := 0 + lv := slog.Any("counter", counterValuer{&v}) + l := slog.New(h.WithAttrs([]slog.Attr{lv})) + + v = 1 + l.Info("first") + v = 2 + l.Info("second") + + ev := backend.GetEvents() + if ev[0].Data["counter"] != int64(1) || ev[1].Data["counter"] != int64(2) { + t.Fatalf("LogValuer not resolved at handle time: %+v", []any{ev[0].Data["counter"], ev[1].Data["counter"]}) + } +} + +func TestEventTypeCanBeOverridden(t *testing.T) { + client, backend := newTestClient() + h := New(client).WithEventType("test") + + slog.New(h).Info("msg", "event_type", "user_value") + + e := backend.GetEvents()[0] + if e.EventType != "user_value" { + t.Errorf("event_type should be overridable per-call, got %v", e.EventType) + } +} + +func TestUserCanSetTimestamp(t *testing.T) { + client, backend := newTestClient() + h := New(client).WithEventType("test") + + slog.New(h).Info("msg", "ts", "2024-01-01T00:00:00Z") + + e := backend.GetEvents()[0] + if e.Data["ts"] != "2024-01-01T00:00:00Z" { + t.Errorf("user should be able to set ts, got %v", e.Data["ts"]) + } +} + +// 1) Immutability: WithGroup/WithAttrs must not mutate the parent handler/logger. +func TestImmutability(t *testing.T) { + client, backend := newTestClient() + h := New(client).WithEventType("test") + + parent := slog.New(h) + child := slog.New(h.WithGroup("http").WithAttrs([]slog.Attr{slog.String("a", "b")})) + + parent.Info("base") + child.Info("child", "k", "v") + + evs := backend.GetEvents() + if len(evs) != 2 { + t.Fatalf("expected 2 events, got %d", len(evs)) + } + if _, ok := evs[0].Data["http"]; ok { + t.Fatal("parent mutated by WithGroup/WithAttrs") + } + if _, ok := evs[1].Data["http"]; !ok { + t.Fatal("child missing group namespace") + } +} + +// 2. Depth semantics: pre-attrs added before a group stay top-level; +// pre-attrs added after a group live under that group. +func TestPreAttrsDepthBeforeAfterGroup(t *testing.T) { + client, backend := newTestClient() + h := New(client).WithEventType("test") + + h1 := h.WithAttrs([]slog.Attr{slog.String("service", "api")}) // depth=0 → top-level + h2 := h1.WithGroup("http").WithAttrs([]slog.Attr{slog.String("method", "GET")}) + + slog.New(h2).Info("msg") + + ev := backend.GetEvents()[0] + if got := ev.Data["service"]; got != "api" { + t.Fatalf("top-level pre-attr lost: %v", got) + } + httpM, ok := ev.Data["http"].(map[string]any) + if !ok { + t.Fatalf("'http' should be a map, got %T", ev.Data["http"]) + } + if httpM["method"] != "GET" { + t.Fatalf("grouped pre-attr missing: %v", httpM["method"]) + } +} + +// 3. Envelope control: event_type can come from handler default (WithEventType) +// but can be overridden per-call via the event_type attribute. +func TestEventTypeDefaultWithOverride(t *testing.T) { + client, backend := newTestClient() + h := New(client).WithEventType("audit") // set default + + logger := slog.New(h) + logger.Info("uses default") // uses "audit" + logger.Info("overrides", "event_type", "custom_type") // overrides to "custom_type" + + evs := backend.GetEvents() + if len(evs) != 2 { + t.Fatalf("expected 2 events, got %d", len(evs)) + } + + if evs[0].EventType != "audit" { + t.Errorf("expected first event type 'audit', got %q", evs[0].EventType) + } + if evs[1].EventType != "custom_type" { + t.Errorf("expected second event type 'custom_type', got %q", evs[1].EventType) + } +} + +func TestLogLevelFiltering(t *testing.T) { + client, backend := newTestClient() + handler := New(client).WithEventType("test").WithLevel(slog.LevelWarn) + + logger := slog.New(handler) + logger.Debug("debug message") + logger.Info("info message") + logger.Warn("warn message") + logger.Error("error message") + + events := backend.GetEvents() + if len(events) != 2 { + t.Fatalf("expected 2 events (warn and error), got %d", len(events)) + } + + if events[0].Data["level"] != "WARN" { + t.Errorf("expected first event level='WARN', got %v", events[0].Data["level"]) + } + if events[1].Data["level"] != "ERROR" { + t.Errorf("expected second event level='ERROR', got %v", events[1].Data["level"]) + } +} + +func TestLogLevelVar(t *testing.T) { + client, backend := newTestClient() + levelVar := new(slog.LevelVar) + levelVar.Set(slog.LevelWarn) + + handler := New(client).WithEventType("test").WithLevel(levelVar) + logger := slog.New(handler) + + logger.Info("info message") + logger.Warn("warn message") + + if len(backend.GetEvents()) != 1 { + t.Fatalf("expected 1 event before level change, got %d", len(backend.GetEvents())) + } + + levelVar.Set(slog.LevelDebug) + logger.Info("second info message") + + events := backend.GetEvents() + if len(events) != 2 { + t.Fatalf("expected 2 events after level change, got %d", len(events)) + } +} + +func TestEventTypePerLogCall(t *testing.T) { + client, backend := newTestClient() + handler := New(client) + logger := slog.New(handler) + + logger.Info("user signup", "event_type", "user_lifecycle", "user_id", 123) + logger.Info("payment processed", "event_type", "payment", "amount", 99.99) + logger.Info("regular log message", "foo", "bar") + + events := backend.GetEvents() + if len(events) != 3 { + t.Fatalf("expected 3 events, got %d", len(events)) + } + + if events[0].EventType != "user_lifecycle" { + t.Errorf("expected first event type 'user_lifecycle', got %q", events[0].EventType) + } + if events[1].EventType != "payment" { + t.Errorf("expected second event type 'payment', got %q", events[1].EventType) + } + if events[2].EventType != "log" { + t.Errorf("expected third event type 'log' (default), got %q", events[2].EventType) + } + + // event_type in data should match the EventType envelope + if events[0].Data["event_type"] != "user_lifecycle" { + t.Errorf("expected data event_type 'user_lifecycle', got %v", events[0].Data["event_type"]) + } + if events[1].Data["event_type"] != "payment" { + t.Errorf("expected data event_type 'payment', got %v", events[1].Data["event_type"]) + } +} diff --git a/test_backend.go b/test_backend.go new file mode 100644 index 0000000..c0f4679 --- /dev/null +++ b/test_backend.go @@ -0,0 +1,36 @@ +package honeybadger + +import "sync" + +type TestBackend struct { + Events []EventData + mu sync.Mutex +} + +type EventData struct { + EventType string + Data map[string]any +} + +func (b *TestBackend) Notify(_ Feature, _ Payload) error { + return nil +} + +func (b *TestBackend) Event(events []*eventPayload) error { + b.mu.Lock() + defer b.mu.Unlock() + for _, e := range events { + eventType, _ := e.data["event_type"].(string) + b.Events = append(b.Events, EventData{ + EventType: eventType, + Data: e.data, + }) + } + return nil +} + +func (b *TestBackend) GetEvents() []EventData { + b.mu.Lock() + defer b.mu.Unlock() + return b.Events +} diff --git a/zerolog/README.md b/zerolog/README.md new file mode 100644 index 0000000..f2b8ebf --- /dev/null +++ b/zerolog/README.md @@ -0,0 +1,33 @@ +# Zerolog adapter for Honeybadger Insights + +Sends zerolog JSON logs to Honeybadger Insights as events. + +Typical usage: + +```go +import ( + "github.com/rs/zerolog" + "github.com/honeybadger-io/honeybadger-go" + hbzerolog "github.com/honeybadger-io/honeybadger-go/zerolog" +) + +hb := honeybadger.New(honeybadger.Configuration{}) +writer := hbzerolog.New( + hb, + hbzerolog.WithEventType("app_log"), + hbzerolog.WithKeys("timestamp", "severity"), +) +log := zerolog.New(writer).With().Timestamp().Logger() +log.Info().Msg("hello") +``` + +## Options + +### WithEventType(string) +Sets the default event type for all logs (default: "log"). Override +per-log by including an `event_type` field. + +### WithKeys(timeKey, levelKey string) +Customize field names if your zerolog uses non-standard keys +(defaults: "time", "level"). The writer remaps the time field to +"ts" for Honeybadger. diff --git a/zerolog/go.mod b/zerolog/go.mod new file mode 100644 index 0000000..8ac9b85 --- /dev/null +++ b/zerolog/go.mod @@ -0,0 +1,21 @@ +module github.com/honeybadger-io/honeybadger-go/zerolog + +go 1.24.0 + +require ( + github.com/honeybadger-io/honeybadger-go v0.0.0 + github.com/rs/zerolog v1.33.0 +) + +require ( + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.0.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pborman/uuid v1.2.1 // indirect + github.com/shirou/gopsutil v3.21.11+incompatible // indirect + github.com/yusufpapurcu/wmi v1.2.3 // indirect + golang.org/x/sys v0.36.0 // indirect +) + +replace github.com/honeybadger-io/honeybadger-go => ../ diff --git a/zerolog/go.sum b/zerolog/go.sum new file mode 100644 index 0000000..be9eb2d --- /dev/null +++ b/zerolog/go.sum @@ -0,0 +1,38 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.0.0 h1:b4Gk+7WdP/d3HZH8EJsZpvV7EtDOgaZLtnaNGIu1adA= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= +github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/zerolog/writer.go b/zerolog/writer.go new file mode 100644 index 0000000..28e725e --- /dev/null +++ b/zerolog/writer.go @@ -0,0 +1,74 @@ +package hbzerolog + +import ( + "bytes" + "encoding/json" + "time" + + "github.com/honeybadger-io/honeybadger-go" + "github.com/rs/zerolog" +) + +type Writer struct { + c *honeybadger.Client + eventType string + timeKey string + levelKey string +} + +type Option func(*Writer) + +func WithEventType(t string) Option { return func(w *Writer) { w.eventType = t } } +func WithKeys(timeKey, levelKey string) Option { + return func(w *Writer) { w.timeKey, w.levelKey = timeKey, levelKey } +} + +func New(c *honeybadger.Client, opts ...Option) *Writer { + w := &Writer{ + c: c, + eventType: "log", + timeKey: "time", + levelKey: "level", + } + for _, o := range opts { + o(w) + } + return w +} + +func (w *Writer) WriteLevel(level zerolog.Level, p []byte) (int, error) { + var m map[string]any + dec := json.NewDecoder(bytes.NewReader(p)) + dec.UseNumber() + if err := dec.Decode(&m); err != nil { + m = map[string]any{"message": string(bytes.TrimSpace(p))} + } + + eventType := w.eventType + if et, ok := m["event_type"].(string); ok && et != "" { + eventType = et + delete(m, "event_type") + } + + if _, ok := m[w.levelKey]; !ok { + if level == zerolog.NoLevel { + level = zerolog.InfoLevel + } + m[w.levelKey] = level.String() + } + if _, ok := m[w.timeKey]; !ok { + m[w.timeKey] = time.Now().UTC().Format(time.RFC3339Nano) + } + + if w.timeKey != "ts" { + if ts, ok := m[w.timeKey]; ok { + m["ts"] = ts + delete(m, w.timeKey) + } + } + + _ = w.c.Event(eventType, m) + return len(p), nil +} + +func (w *Writer) Write(p []byte) (int, error) { return w.WriteLevel(zerolog.NoLevel, p) }