From c6f1ab349a67f30e4b58461dd48bf64c84bc69a8 Mon Sep 17 00:00:00 2001 From: James Lindsay Date: Wed, 7 Aug 2024 14:35:05 +0100 Subject: [PATCH] Add breadcrumb support --- v2/breadcrumb.go | 97 +++++++++++++++++ v2/breadcrumb_test.go | 239 ++++++++++++++++++++++++++++++++++++++++++ v2/bugsnag.go | 15 ++- v2/configuration.go | 7 ++ v2/event.go | 3 + v2/notifier.go | 34 +++++- v2/payload.go | 27 ++++- v2/report.go | 8 ++ 8 files changed, 423 insertions(+), 7 deletions(-) create mode 100644 v2/breadcrumb.go create mode 100644 v2/breadcrumb_test.go diff --git a/v2/breadcrumb.go b/v2/breadcrumb.go new file mode 100644 index 00000000..39f8d771 --- /dev/null +++ b/v2/breadcrumb.go @@ -0,0 +1,97 @@ +package bugsnag + +type BreadcrumbType = string + +const ( + // Changing screens or content being displayed, with a defined destination and optionally a previous location. + BreadcrumbTypeNavigation BreadcrumbType = "navigation" + // Sending and receiving requests and responses. + BreadcrumbTypeRequest BreadcrumbType = "request" + // Performing an intensive task or query. + BreadcrumbTypeProcess BreadcrumbType = "process" + // Messages and severity sent to a logging platform. + BreadcrumbTypeLog BreadcrumbType = "log" + // Actions performed by the user, like text input, button presses, or confirming/cancelling an alert dialog. + BreadcrumbTypeUser BreadcrumbType = "user" + // Changing the overall state of an app, such as closing, pausing, or being moved to the background, as well as device state changes like memory or battery warnings and network connectivity changes. + BreadcrumbTypeState BreadcrumbType = "state" + // An error which was reported to Bugsnag encountered in the same session. + BreadcrumbTypeError BreadcrumbType = "error" + // User-defined, manually added breadcrumbs. + BreadcrumbTypeManual BreadcrumbType = "manual" +) + +// Key value metadata that is displayed with the breadcrumb. +type BreadcrumbMetaData map[string]interface{} + +// Remove any values from meta-data that have keys matching the filters, +// and any that are recursive data-structures. +func (meta BreadcrumbMetaData) sanitize(filters []string) interface{} { + return sanitizer{ + Filters: filters, + Seen: make([]interface{}, 0), + }.Sanitize(meta) +} + +type Breadcrumb struct { + // The time at which the event occurred, in ISO 8601 format. + Timestamp string + // A short summary describing the event, such as the user action taken or a new application state. + Name string + // A category which describes the breadcrumb. + Type BreadcrumbType + // Additional information about the event, as key/value pairs. + MetaData BreadcrumbMetaData +} + +type ( + // A breadcrumb callback that returns if the breadcrumb should be added. + OnBreadcrumbCallback func(*Breadcrumb) bool + + BreadcrumbState struct { + // These callbacks are run in reverse order and determine if the breadcrumb should be added. + OnBreadcrumbCallbacks []OnBreadcrumbCallback + // Currently added breadcrumbs in order from newest to oldest + Breadcrumbs []Breadcrumb + } +) + +// OnBreadcrumb adds a callback to be run before a breadcrumb is added. +// If false is returned, the breadcrumb will be discarded. +func (breadcrumbs *BreadcrumbState) OnBreadcrumb(callback OnBreadcrumbCallback) { + if breadcrumbs.OnBreadcrumbCallbacks == nil { + breadcrumbs.OnBreadcrumbCallbacks = []OnBreadcrumbCallback{} + } + + breadcrumbs.OnBreadcrumbCallbacks = append(breadcrumbs.OnBreadcrumbCallbacks, callback) +} + +// Runs all the OnBreadcrumb callbacks, returning true if the breadcrumb should be added. +func (breadcrumbs *BreadcrumbState) runBreadcrumbCallbacks(breadcrumb *Breadcrumb) bool { + if breadcrumbs.OnBreadcrumbCallbacks == nil { + return true + } + + // run in reverse order + for i := range breadcrumbs.OnBreadcrumbCallbacks { + callback := breadcrumbs.OnBreadcrumbCallbacks[len(breadcrumbs.OnBreadcrumbCallbacks)-i-1] + if !callback(breadcrumb) { + return false + } + } + return true +} + +// Add the breadcrumb onto the list of breadcrumbs, ensuring that the number of breadcrumbs remains below maximumBreadcrumbs. +func (breadcrumbs *BreadcrumbState) appendBreadcrumb(breadcrumb Breadcrumb, maximumBreadcrumbs int) error { + if breadcrumbs.runBreadcrumbCallbacks(&breadcrumb) { + if breadcrumbs.Breadcrumbs == nil { + breadcrumbs.Breadcrumbs = []Breadcrumb{} + } + breadcrumbs.Breadcrumbs = append([]Breadcrumb{breadcrumb}, breadcrumbs.Breadcrumbs...) + if len(breadcrumbs.Breadcrumbs) > 0 && len(breadcrumbs.Breadcrumbs) > maximumBreadcrumbs { + breadcrumbs.Breadcrumbs = breadcrumbs.Breadcrumbs[:len(breadcrumbs.Breadcrumbs)-1] + } + } + return nil +} diff --git a/v2/breadcrumb_test.go b/v2/breadcrumb_test.go new file mode 100644 index 00000000..51765bb1 --- /dev/null +++ b/v2/breadcrumb_test.go @@ -0,0 +1,239 @@ +package bugsnag_test + +import ( + "fmt" + "net/http/httptest" + "testing" + + "github.com/bitly/go-simplejson" + "github.com/bugsnag/bugsnag-go/v2" + "github.com/bugsnag/bugsnag-go/v2/testutil" +) + +func TestDefaultBreadcrumbValues(t *testing.T) { + testServer, reports, notifier := setupServer(bugsnag.Configuration{}) + defer testServer.Close() + notifier.LeaveBreadcrumb("test breadcrumb") + notifier.Notify(fmt.Errorf("test error")) + breadcrumbs := getBreadcrumbs(reports) + + if len(breadcrumbs) != 1 { + t.Fatal("expected 1 breadcrumb") + } + if breadcrumbs[0].Name != "test breadcrumb" { + t.Fatal("expected breadcrumb name") + } + if len(breadcrumbs[0].Timestamp) < 6 { + t.Fatal("expected timestamp") + } + if len(breadcrumbs[0].MetaData) != 0 { + t.Fatal("expected no metadata") + } + if breadcrumbs[0].Type != bugsnag.BreadcrumbTypeManual { + t.Fatal("expected manual type") + } +} + +func TestCustomBreadcrumbValues(t *testing.T) { + testServer, reports, notifier := setupServer(bugsnag.Configuration{}) + defer testServer.Close() + notifier.LeaveBreadcrumb("test breadcrumb", bugsnag.BreadcrumbMetaData{"hello": "world"}, bugsnag.BreadcrumbTypeProcess) + notifier.Notify(fmt.Errorf("test error")) + breadcrumbs := getBreadcrumbs(reports) + + if len(breadcrumbs) != 1 { + t.Fatal("expected 1 breadcrumb") + } + if breadcrumbs[0].Name != "test breadcrumb" { + t.Fatal("expected breadcrumb name") + } + if len(breadcrumbs[0].Timestamp) < 6 { + t.Fatal("expected timestamp") + } + if len(breadcrumbs[0].MetaData) != 1 || breadcrumbs[0].MetaData["hello"] != "world" { + t.Fatal("expected correct metadata") + } + if breadcrumbs[0].Type != bugsnag.BreadcrumbTypeProcess { + t.Fatal("expected process type") + } +} + +func TestDefaultMaxBreadcrumbs(t *testing.T) { + testServer, reports, notifier := setupServer(bugsnag.Configuration{}) + defer testServer.Close() + defaultMaximum := 25 + + for i := 1; i <= defaultMaximum*2; i++ { + notifier.LeaveBreadcrumb(fmt.Sprintf("breadcrumb%v", i)) + } + + notifier.Notify(fmt.Errorf("test error")) + breadcrumbs := getBreadcrumbs(reports) + + if len(breadcrumbs) != defaultMaximum { + t.Fatal("incorrect number of breadcrumbs") + } + for i := 0; i < defaultMaximum; i++ { + if breadcrumbs[i].Name != fmt.Sprintf("breadcrumb%v", defaultMaximum*2-i) { + t.Fatal("invalid breadcrumb at ", i) + } + } +} + +func TestCustomMaxBreadcrumbs(t *testing.T) { + customMaximum := 5 + testServer, reports, notifier := setupServer(bugsnag.Configuration{MaximumBreadcrumbs: customMaximum}) + defer testServer.Close() + + for i := 1; i <= customMaximum*2; i++ { + notifier.LeaveBreadcrumb(fmt.Sprintf("breadcrumb%v", i)) + } + + notifier.Notify(fmt.Errorf("test error")) + breadcrumbs := getBreadcrumbs(reports) + + if len(breadcrumbs) != customMaximum { + t.Fatal("incorrect number of breadcrumbs") + } + for i := 0; i < customMaximum; i++ { + if breadcrumbs[i].Name != fmt.Sprintf("breadcrumb%v", customMaximum*2-i) { + t.Fatal("invalid breadcrumb at ", i) + } + } +} + +func TestBreadcrumbCallbacksAreReversed(t *testing.T) { + testServer, reports, notifier := setupServer(bugsnag.Configuration{}) + defer testServer.Close() + + callback1Called := false + callback2Called := false + notifier.BreadcrumbState.OnBreadcrumb(func(breadcrumb *bugsnag.Breadcrumb) bool { + callback2Called = true + if breadcrumb.Name != "breadcrumb" { + t.Fatal("incorrect name") + } + if callback1Called == false { + t.Fatal("callbacks should occur in reverse order") + } + return true + }) + notifier.BreadcrumbState.OnBreadcrumb(func(breadcrumb *bugsnag.Breadcrumb) bool { + callback1Called = true + if breadcrumb.Name != "breadcrumb" { + t.Fatal("incorrect name") + } + if callback2Called == true { + t.Fatal("callbacks should occur in reverse order") + } + return true + }) + + notifier.LeaveBreadcrumb("breadcrumb") + + if !callback2Called { + t.Fatal("breadcrumb callback not called") + } + + notifier.Notify(fmt.Errorf("test error")) + if len(getBreadcrumbs(reports)) != 1 { + t.Fatal("expected one breadcrumb") + } +} + +func TestBreadcrumbCallbacksCanCancel(t *testing.T) { + testServer, reports, notifier := setupServer(bugsnag.Configuration{}) + defer testServer.Close() + + callbackCalled := false + notifier.BreadcrumbState.OnBreadcrumb(func(breadcrumb *bugsnag.Breadcrumb) bool { + t.Fatal("Callback should be canceled") + return true + }) + notifier.BreadcrumbState.OnBreadcrumb(func(breadcrumb *bugsnag.Breadcrumb) bool { + callbackCalled = true + return false + }) + + notifier.LeaveBreadcrumb("breadcrumb") + + if !callbackCalled { + t.Fatal("first breadcrumb callback not called") + } + + notifier.Notify(fmt.Errorf("test error")) + if len(getBreadcrumbs(reports)) != 0 { + t.Fatal("breadcrumb not canceled") + } +} + +func TestSendNoBreadcrumbs(t *testing.T) { + testServer, reports, notifier := setupServer(bugsnag.Configuration{}) + defer testServer.Close() + notifier.Notify(fmt.Errorf("test error")) + if len(getBreadcrumbs(reports)) != 0 { + t.Fatal("expected no breadcrumbs") + } +} + +func TestSendOrderedBreadcrumbs(t *testing.T) { + testServer, reports, notifier := setupServer(bugsnag.Configuration{}) + defer testServer.Close() + notifier.LeaveBreadcrumb("breadcrumb1") + notifier.LeaveBreadcrumb("breadcrumb2") + notifier.Notify(fmt.Errorf("test error")) + breadcrumbs := getBreadcrumbs(reports) + if len(breadcrumbs) != 2 { + t.Fatal("expected 2 breadcrumbs", breadcrumbs) + } + if breadcrumbs[0].Name != "breadcrumb2" || breadcrumbs[1].Name != "breadcrumb1" { + t.Fatal("expected ordered breadcrumbs", breadcrumbs) + } +} + +func TestSendCleanMetadata(t *testing.T) { + testServer, reports, notifier := setupServer(bugsnag.Configuration{}) + defer testServer.Close() + type Recursive struct { + Inner *Recursive + } + recursiveValue := Recursive{} + recursiveValue.Inner = &recursiveValue + notifier.LeaveBreadcrumb("breadcrumb2", bugsnag.BreadcrumbMetaData{"recursive": recursiveValue}) + notifier.Notify(fmt.Errorf("test error")) + breadcrumbs := getBreadcrumbs(reports) + if len(breadcrumbs) != 1 { + t.Fatal("expected 1 breadcrumb", breadcrumbs) + } + if breadcrumbs[0].MetaData["recursive"].(map[string]interface{})["Inner"] != "[RECURSION]" { + t.Fatal("remove recursive") + } +} + +func getBreadcrumbs(reports chan []byte) []bugsnag.Breadcrumb { + event, _ := simplejson.NewJson(<-reports) + fmt.Println(event) + firstEventJson := testutil.GetIndex(event, "events", 0) + breadcrumbsJson := testutil.Get(firstEventJson, "breadcrumbs") + + breadcrumbs := []bugsnag.Breadcrumb{} + for index := 0; index < len(breadcrumbsJson.MustArray()); index++ { + breadcrumbJson := breadcrumbsJson.GetIndex(index) + fmt.Println(breadcrumbJson) + breadcrumbs = append(breadcrumbs, bugsnag.Breadcrumb{ + Timestamp: breadcrumbJson.Get("timestamp").MustString(), + Name: breadcrumbJson.Get("name").MustString(), + Type: breadcrumbJson.Get("type").MustString(), + MetaData: breadcrumbJson.Get("metaData").MustMap(), + }) + } + return breadcrumbs +} + +func setupServer(configuration bugsnag.Configuration) (*httptest.Server, chan []byte, *bugsnag.Notifier) { + testServer, reports := testutil.Setup() + configuration.APIKey = testutil.TestAPIKey + configuration.Endpoints = bugsnag.Endpoints{Notify: testServer.URL, Sessions: testServer.URL + "/sessions"} + notifier := bugsnag.New(configuration) + return testServer, reports, notifier +} diff --git a/v2/bugsnag.go b/v2/bugsnag.go index 631d2e93..3e909efe 100644 --- a/v2/bugsnag.go +++ b/v2/bugsnag.go @@ -36,7 +36,7 @@ var sessionTrackingConfig sessions.SessionTrackingConfiguration // Bugsnag. // Deprecated: Exposed for developer sanity in testing. Modify at own risk. var DefaultSessionPublishInterval = 60 * time.Second -var defaultNotifier = Notifier{&Config, nil} +var defaultNotifier = Notifier{&Config, nil, BreadcrumbState{}} var sessionTracker sessions.SessionTracker // Configure Bugsnag. The only required setting is the APIKey, which can be @@ -155,6 +155,12 @@ func Recover(rawData ...interface{}) { } } +// OnBeforeNotify adds a callback to be run before a breadcrumb is added to the default notifier. +// If false is returned, the breadcrumb will be discarded. These callbacks are run in reverse order. +func OnBreadcrumb(callback func(breadcrumb *Breadcrumb) bool) { + defaultNotifier.BreadcrumbState.OnBreadcrumb(callback) +} + // OnBeforeNotify adds a callback to be run before a notification is sent to // Bugsnag. It can be used to modify the event or its MetaData. Changes made // to the configuration are local to notifying about this event. To prevent the @@ -212,6 +218,12 @@ func HandlerFunc(h http.HandlerFunc, rawData ...interface{}) http.HandlerFunc { } } +// Adds a breadcrumb to the default notifier which is sent with subsequent errors. +// Optionally accepts bugsnag.BreadcrumbMetaData and bugsnag.BreadcrumbType. +func LeaveBreadcrumb(message string, rawData ...interface{}) { + defaultNotifier.LeaveBreadcrumb(message, rawData...) +} + // checkForEmptyError checks if the given error (to be reported to Bugsnag) is // nil. If it is, then log an error message and return another error wrapping // this error message. @@ -253,6 +265,7 @@ func init() { Logger: log.New(os.Stdout, log.Prefix(), log.Flags()), PanicHandler: defaultPanicHandler, Transport: http.DefaultTransport, + MaximumBreadcrumbs: 25, flushSessionsOnRepanic: true, }) diff --git a/v2/configuration.go b/v2/configuration.go index 08ecb7ee..2887e35f 100644 --- a/v2/configuration.go +++ b/v2/configuration.go @@ -108,6 +108,10 @@ type Configuration struct { // the event sending goroutine will switch to a graceful shutdown // and will try to send any remaining events. MainContext context.Context + + // The largets number of breadcrumbs that will be stored. Defaults to 25. + MaximumBreadcrumbs int + // Whether the notifier should send all sessions recorded so far to Bugsnag // when repanicking to ensure that no session information is lost in a // fatal crash. @@ -171,6 +175,9 @@ func (config *Configuration) update(other *Configuration) *Configuration { config.MainContext = other.MainContext publisher.setMainProgramContext(other.MainContext) } + if other.MaximumBreadcrumbs != 0 { + config.MaximumBreadcrumbs = other.MaximumBreadcrumbs + } if other.AutoCaptureSessions != nil { config.AutoCaptureSessions = other.AutoCaptureSessions diff --git a/v2/event.go b/v2/event.go index 742084ed..b5c38264 100644 --- a/v2/event.go +++ b/v2/event.go @@ -79,6 +79,8 @@ type Event struct { // The rawData affecting this error, not sent to Bugsnag. RawData []interface{} + // The breadcrumbs to be sent to Bugsnag. + Breadcrumbs []Breadcrumb // The error class to be sent to Bugsnag. This defaults to the type name of the Error, for // example *error.String ErrorClass string @@ -180,6 +182,7 @@ func newEvent(rawData []interface{}, notifier *Notifier) (*Event, *Configuration } event.Stacktrace = generateStacktrace(err, config) + event.Breadcrumbs = notifier.BreadcrumbState.Breadcrumbs for _, callback := range callbacks { callback(event) diff --git a/v2/notifier.go b/v2/notifier.go index 876dcc8a..85f413a8 100644 --- a/v2/notifier.go +++ b/v2/notifier.go @@ -1,6 +1,8 @@ package bugsnag import ( + "time" + "github.com/bugsnag/bugsnag-go/v2/errors" ) @@ -8,8 +10,9 @@ var publisher reportPublisher = newPublisher() // Notifier sends errors to Bugsnag. type Notifier struct { - Config *Configuration - RawData []interface{} + Config *Configuration + RawData []interface{} + BreadcrumbState BreadcrumbState } // New creates a new notifier. @@ -25,8 +28,9 @@ func New(rawData ...interface{}) *Notifier { } return &Notifier{ - Config: config, - RawData: rawData, + Config: config, + RawData: rawData, + BreadcrumbState: BreadcrumbState{}, } } @@ -116,6 +120,28 @@ func (notifier *Notifier) Recover(rawData ...interface{}) { } } +// Adds a breadcrumb to the current notifier which is sent with subsequent errors. +// Optionally accepts bugsnag.BreadcrumbMetaData and bugsnag.BreadcrumbType. +func (notifier *Notifier) LeaveBreadcrumb(message string, rawData ...interface{}) { + breadcrumb := Breadcrumb{ + Timestamp: time.Now().Format(time.RFC3339), + Name: message, + Type: BreadcrumbTypeManual, + MetaData: BreadcrumbMetaData{}, + } + for _, datum := range rawData { + switch datum := datum.(type) { + case BreadcrumbMetaData: + breadcrumb.MetaData = datum + case BreadcrumbType: + breadcrumb.Type = datum + default: + panic("Unexpected type") + } + } + notifier.BreadcrumbState.appendBreadcrumb(breadcrumb, notifier.Config.MaximumBreadcrumbs) +} + func (notifier *Notifier) dontPanic() { if err := recover(); err != nil { notifier.Config.logf("bugsnag/notifier.Notify: panic! %s", err) diff --git a/v2/payload.go b/v2/payload.go index be2234ce..4e46b7ef 100644 --- a/v2/payload.go +++ b/v2/payload.go @@ -76,8 +76,9 @@ func (p *payload) MarshalJSON() ([]byte, error) { OsName: runtime.GOOS, RuntimeVersions: device.GetRuntimeVersions(), }, - Request: p.Request, + Request: p.Request, Exceptions: p.exceptions(), + Breadcrumbs: p.breadcrumbs(), GroupingHash: p.GroupingHash, Metadata: p.MetaData.sanitize(p.ParamsFilters), PayloadVersion: notifyPayloadVersion, @@ -123,7 +124,7 @@ func (p *payload) makeSession() *sessionJSON { func (p *payload) severityReasonPayload() *severityReasonJSON { if reason := p.handledState.SeverityReason; reason != "" { json := &severityReasonJSON{ - Type: reason, + Type: reason, UnhandledOverridden: p.handledState.Unhandled != p.Unhandled, } if p.handledState.Framework != "" { @@ -160,3 +161,25 @@ func (p *payload) exceptions() []exceptionJSON { return exceptions } + +func (p *payload) breadcrumbs() []breadcrumbJSON { + if p.Breadcrumbs == nil { + return []breadcrumbJSON{} + } + + breadcrumbs := []breadcrumbJSON{} + for _, sourceBreadcrumb := range p.Breadcrumbs { + breadcrumbJson := breadcrumbJSON{ + Timestamp: sourceBreadcrumb.Timestamp, + Name: sourceBreadcrumb.Name, + Type: sourceBreadcrumb.Type, + MetaData: BreadcrumbMetaData{}, + } + if sourceBreadcrumb.MetaData != nil { + breadcrumbJson.MetaData = sourceBreadcrumb.MetaData.sanitize(p.ParamsFilters) + } + breadcrumbs = append(breadcrumbs, breadcrumbJson) + } + + return breadcrumbs +} diff --git a/v2/report.go b/v2/report.go index e7e145aa..c49c3907 100644 --- a/v2/report.go +++ b/v2/report.go @@ -24,6 +24,7 @@ type eventJSON struct { Device *deviceJSON `json:"device,omitempty"` Request *RequestJSON `json:"request,omitempty"` Exceptions []exceptionJSON `json:"exceptions"` + Breadcrumbs []breadcrumbJSON `json:"breadcrumbs,omitempty"` GroupingHash string `json:"groupingHash,omitempty"` Metadata interface{} `json:"metaData"` PayloadVersion string `json:"payloadVersion"` @@ -46,6 +47,13 @@ type appJSON struct { Version string `json:"version,omitempty"` } +type breadcrumbJSON struct { + Timestamp string `json:"timestamp"` + Name string `json:"name"` + Type BreadcrumbType `json:"type"` + MetaData interface{} `json:"metaData,omitempty"` +} + type exceptionJSON struct { ErrorClass string `json:"errorClass"` Message string `json:"message"`