Skip to content

Commit

Permalink
Add FromContext and Context.WithContext for lots of context
Browse files Browse the repository at this point in the history
This allows building up a honeybadger.Context riding along with a
context.Context so it can be sent with errors. The goal is to make it so
that the context sent to Honeybadger is tied to the request (or stack)
rather than multiple requests clobbering the global state (see honeybadger-io#35).

Fixes honeybadger-io#35
Closes honeybadger-io#37
  • Loading branch information
gaffneyc committed Jan 9, 2020
1 parent cdfc826 commit 0b8b519
Show file tree
Hide file tree
Showing 9 changed files with 185 additions and 6 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ CHANGELOG](http://keepachangelog.com/) for how to update this file. This project
adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased][unreleased]
### Added
- Added `honeybadger.FromContext` to retrieve a honeybadger.Context from a
context.Context.
- Added `Context.WithContext` for storing a honeybadger.Context into a
context.Context.

### Changed
- Removed honeybadger.SetContext and client.SetContext (#35) -@gaffneyc
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,26 @@ honeybadger.Notify(err, honeybadger.Tags{"timeout", "http"})

---

When using Go's context.Context you can store a honeybadger.Context to build it
up across multiple middleware. Be aware that honeybadger.Context is not thread
safe.
```go
func(resp http.ResponseWriter, req *http.Request) {
// To store a honeybadger.Context (or use honeybadger.Handler which does this for you)
hbCtx := honeybadger.Context{}
req = req.WithContext(hbCtx.WithContext(req.Context()))

// To add to an existing context
hbCtx = honeybadger.FromContext(req.Context())
hbCtx["user_id"] = "ID"

// To add the context when sending you can just pass the context.Context
honeybadger.Notify(err, ctx)
}
```

---

### ``defer honeybadger.Monitor()``: Automatically report panics from your functions

To automatically report panics in your functions or methods, add
Expand Down
12 changes: 9 additions & 3 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,20 @@ func (client *Client) Handler(h http.Handler) http.Handler {
if h == nil {
h = http.DefaultServeMux
}
fn := func(w http.ResponseWriter, r *http.Request) {
fn := func(w http.ResponseWriter, req *http.Request) {
defer func() {
if err := recover(); err != nil {
client.Notify(newError(err, 2), Params(r.Form), getCGIData(r), *r.URL)
client.Notify(newError(err, 2), Params(req.Form), getCGIData(req), *req.URL)
panic(err)
}
}()
h.ServeHTTP(w, r)

// Add a fresh Context to the request if one is not already set
if hbCtx := FromContext(req.Context()); hbCtx == nil {
req = req.WithContext(Context{}.WithContext(req.Context()))
}

h.ServeHTTP(w, req)
}
return http.HandlerFunc(fn)
}
Expand Down
36 changes: 36 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package honeybadger

import (
"context"
"fmt"
"testing"
)

Expand Down Expand Up @@ -84,3 +86,37 @@ func mockClient(c Configuration) (Client, *mockWorker, *mockBackend) {

return client, worker, backend
}

func TestClientContext(t *testing.T) {
backend := NewMemoryBackend()

client := New(Configuration{
APIKey: "badgers",
Backend: backend,
})

err := NewError(fmt.Errorf("which context is which"))

hbCtx := Context{"user_id": 1}
goCtx := Context{"request_id": "1234"}.WithContext(context.Background())

_, nErr := client.Notify(err, hbCtx, goCtx)
if nErr != nil {
t.Fatal(nErr)
}

// Flush otherwise backend.Notices will be empty
client.Flush()

if len(backend.Notices) != 1 {
t.Fatalf("Notices expected=%d actual=%d", 1, len(backend.Notices))
}

notice := backend.Notices[0]
if notice.Context["user_id"] != 1 {
t.Errorf("notice.Context[user_id] expected=%d actual=%v", 1, notice.Context["user_id"])
}
if notice.Context["request_id"] != "1234" {
t.Errorf("notice.Context[request_id] expected=%q actual=%v", "1234", notice.Context["request_id"])
}
}
26 changes: 24 additions & 2 deletions context.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,33 @@
package honeybadger

import "context"

// Context is used to send extra data to Honeybadger.
type Context hash

// ctxKey is use in WithContext and FromContext to store and load the
// honeybadger.Context into a context.Context.
type ctxKey struct{}

// Update applies the values in other Context to context.
func (context Context) Update(other Context) {
func (c Context) Update(other Context) {
for k, v := range other {
context[k] = v
c[k] = v
}
}

// WithContext adds the honeybadger.Context to the given context.Context and
// returns the new context.Context.
func (c Context) WithContext(ctx context.Context) context.Context {
return context.WithValue(ctx, ctxKey{}, c)
}

// FromContext retrieves a honeybadger.Context from the context.Context.
// FromContext will return nil if no Honeybadger context exists in ctx.
func FromContext(ctx context.Context) Context {
if c, ok := ctx.Value(ctxKey{}).(Context); ok {
return c
}

return nil
}
28 changes: 27 additions & 1 deletion context_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package honeybadger

import "testing"
import (
"context"
"testing"
)

func TestContextUpdate(t *testing.T) {
c := Context{"foo": "bar"}
Expand All @@ -9,3 +12,26 @@ func TestContextUpdate(t *testing.T) {
t.Errorf("Context should update values. expected=%#v actual=%#v", "baz", c["foo"])
}
}

func TestContext(t *testing.T) {
t.Run("setting values is allowed between reads", func(t *testing.T) {
ctx := context.Background()
ctx = Context{"foo": "bar"}.WithContext(ctx)

stored := FromContext(ctx)
if stored == nil {
t.Fatalf("FromContext returned nil")
}
if stored["foo"] != "bar" {
t.Errorf("stored[foo] expected=%q actual=%v", "bar", stored["foo"])
}

// Write a new key then we'll read from the ctx again and make sure it is
// still set.
stored["baz"] = "qux"
stored = FromContext(ctx)
if stored["baz"] != "qux" {
t.Errorf("stored[baz] expected=%q actual=%v", "qux", stored["baz"])
}
})
}
45 changes: 45 additions & 0 deletions memory_backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package honeybadger

import (
"fmt"
"reflect"
"sync"
)

// MemoryBackend is a Backend that writes error notices to a slice. The
// MemoryBackend is mainly useful for testing and will cause issues if used in
// production. MemoryBackend is thread safe but order can't be guaranteed.
type MemoryBackend struct {
Notices []*Notice
mu sync.Mutex
}

// NewMemoryBackend creates a new MemoryBackend.
func NewMemoryBackend() *MemoryBackend {
return &MemoryBackend{
Notices: make([]*Notice, 0),
}
}

// Notify adds the given payload (if it is a Notice) to Notices.
func (b *MemoryBackend) Notify(_ Feature, payload Payload) error {
notice, ok := payload.(*Notice)
if !ok {
return fmt.Errorf("memory backend does not support payload of type %q", reflect.TypeOf(payload))
}

b.mu.Lock()
defer b.mu.Unlock()

b.Notices = append(b.Notices, notice)

return nil
}

// Reset clears the set of Notices
func (b *MemoryBackend) Reset() {
b.mu.Lock()
defer b.mu.Unlock()

b.Notices = b.Notices[:0]
}
6 changes: 6 additions & 0 deletions notice.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package honeybadger

import (
"context"
"encoding/json"
"net/url"
"os"
Expand Down Expand Up @@ -174,6 +175,11 @@ func newNotice(config *Configuration, err Error, extra ...interface{}) *Notice {
notice.CGIData = t
case url.URL:
notice.URL = t.String()
case context.Context:
context := FromContext(t)
if context != nil {
notice.setContext(context)
}
}
}

Expand Down
13 changes: 13 additions & 0 deletions notice_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package honeybadger

import (
"context"
"encoding/json"
"errors"
"testing"
Expand Down Expand Up @@ -57,6 +58,18 @@ func TestNewNotice(t *testing.T) {
if notice.Context["foo"] != "bar" {
t.Errorf("Expected notice to contain context. expected=%#v result=%#v", "bar", notice.Context["foo"])
}

// Notices can take the Context that is stored in a context.Context
notice = newNotice(&Configuration{}, err, Context{"foo": "bar"}.WithContext(context.Background()))
if notice.Context["foo"] != "bar" {
t.Errorf("Expected notice to contain context. expected=%#v result=%#v", "bar", notice.Context["foo"])
}

// Notices given a context.Context without a Context don't set notice.Context
notice = newNotice(&Configuration{}, err, context.Background())
if len(notice.Context) != 0 {
t.Errorf("Expected notice to contain empty context. result=%#v", notice.Context)
}
}

func TestToJSON(t *testing.T) {
Expand Down

0 comments on commit 0b8b519

Please sign in to comment.