diff --git a/.github/workflows/test-package.yml b/.github/workflows/test-package.yml index adddc5a..9f875a8 100644 --- a/.github/workflows/test-package.yml +++ b/.github/workflows/test-package.yml @@ -14,6 +14,8 @@ jobs: matrix: os: [ubuntu-20.04, windows-latest] go-version: ['1.11', '1.12', '1.13', '1.14', '1.15', '1.16', '1.17', '1.18', '1.19', '1.20', '1.21', '1.22'] + env: + GO111MODULE: on steps: - uses: actions/checkout@v2 diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..2c9b4ef --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.7.3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 409bb4d..4418fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ * Limit resource usage while sending events asynchronously \ Added MainContext configuration option for providing context from main app [#231](https://github.com/bugsnag/bugsnag-go/pull/231) +* Add breadcrumb support + [#234](https://github.com/bugsnag/bugsnag-go/pull/234) ## 2.4.0 (2024-04-15) diff --git a/Makefile b/Makefile index 665f82a..192b6cc 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ TEST?=./... -export GO111MODULE=auto +export GO111MODULE=on default: alldeps test diff --git a/features/breadcrumbs.feature b/features/breadcrumbs.feature new file mode 100644 index 0000000..a634e3b --- /dev/null +++ b/features/breadcrumbs.feature @@ -0,0 +1,47 @@ +Feature: Breadcrumbs + +Background: + Given I set environment variable "API_KEY" to "a35a2a72bd230ac0aa0f52715bbdc6aa" + Given I configure the bugsnag endpoint + Given I have built the service "app" + +Scenario: Disabling breadcrumbs + Given I set environment variable "ENABLED_BREADCRUMB_TYPES" to "[]" + When I run the go service "app" with the test case "disable-breadcrumbs" + When I wait to receive 2 requests after the start up session + + Then the request 0 is a valid error report with api key "a35a2a72bd230ac0aa0f52715bbdc6aa" + Then the payload field "events.0.breadcrumbs" is null for request 0 + + Then the request 1 is a valid error report with api key "a35a2a72bd230ac0aa0f52715bbdc6aa" + Then the payload field "events.0.breadcrumbs" is null for request 1 + +Scenario: Automatic breadcrumbs + When I run the go service "app" with the test case "automatic-breadcrumbs" + When I wait to receive 2 requests after the start up session + + Then the request 0 is a valid error report with api key "a35a2a72bd230ac0aa0f52715bbdc6aa" + Then the payload field "events.0.breadcrumbs" is an array with 1 elements for request 0 + Then the payload field "events.0.breadcrumbs.0.name" equals "Bugsnag loaded" for request 0 + Then the payload field "events.0.breadcrumbs.0.type" equals "state" for request 0 + + Then the request 1 is a valid error report with api key "a35a2a72bd230ac0aa0f52715bbdc6aa" + Then the payload field "events.0.breadcrumbs" is an array with 2 elements for request 1 + Then the payload field "events.0.breadcrumbs.0.name" equals "oops" for request 1 + Then the payload field "events.0.breadcrumbs.0.type" equals "error" for request 1 + Then the payload field "events.0.breadcrumbs.1.name" equals "Bugsnag loaded" for request 1 + Then the payload field "events.0.breadcrumbs.1.type" equals "state" for request 1 + +Scenario: Setting max breadcrumbs + Given I set environment variable "ENABLED_BREADCRUMB_TYPES" to "[]" + Given I set environment variable "MAXIMUM_BREADCRUMBS" to "5" + When I run the go service "app" with the test case "maximum-breadcrumbs" + When I wait to receive 1 requests after the start up session + + Then the request 0 is a valid error report with api key "a35a2a72bd230ac0aa0f52715bbdc6aa" + Then the payload field "events.0.breadcrumbs" is an array with 5 elements + Then the payload field "events.0.breadcrumbs.0.name" equals "Crumb 9" + Then the payload field "events.0.breadcrumbs.1.name" equals "Crumb 8" + Then the payload field "events.0.breadcrumbs.2.name" equals "Crumb 7" + Then the payload field "events.0.breadcrumbs.3.name" equals "Crumb 6" + Then the payload field "events.0.breadcrumbs.4.name" equals "Crumb 5" diff --git a/features/fixtures/app/Dockerfile b/features/fixtures/app/Dockerfile index e046de7..a06a614 100644 --- a/features/fixtures/app/Dockerfile +++ b/features/fixtures/app/Dockerfile @@ -11,6 +11,9 @@ WORKDIR /app/src/github.com/bugsnag/bugsnag-go/v2 # Ensure subsequent steps are re-run if the GO_VERSION variable changes ARG GO_VERSION +# Required for go 1.11 as modules are not enabled by default +ENV GO111MODULE="on" + # Get bugsnag dependencies using a conditional call to run go get or go install based on the go version RUN if [[ $(echo -e "1.11\n$GO_VERSION\n1.16" | sort -V | head -2 | tail -1) == "$GO_VERSION" ]]; then \ echo "Version is between 1.11 and 1.16, running go get"; \ diff --git a/features/fixtures/app/main.go b/features/fixtures/app/main.go index 9624353..680b3d8 100644 --- a/features/fixtures/app/main.go +++ b/features/fixtures/app/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "flag" "fmt" "log" @@ -45,6 +46,15 @@ func configureBasicBugsnag(testcase string, ctx context.Context) { config.AutoCaptureSessions = acs } + if maximumBreadcrumbs, err := strconv.Atoi(os.Getenv("MAXIMUM_BREADCRUMBS")); err == nil { + config.MaximumBreadcrumbs = bugsnag.MaximumBreadcrumbs(maximumBreadcrumbs) + } + + var enabledBreadcrumbTypes []bugsnag.BreadcrumbType + if err := json.Unmarshal([]byte(os.Getenv("ENABLED_BREADCRUMB_TYPES")), &enabledBreadcrumbTypes); err == nil { + config.EnabledBreadcrumbTypes = enabledBreadcrumbTypes + } + switch testcase { case "endpoint-notify": config.Endpoints = bugsnag.Endpoints{Notify: os.Getenv("BUGSNAG_ENDPOINT")} @@ -98,7 +108,7 @@ func main() { sendAndExit() case "user": user() - case "multiple-handled": + case "multiple-handled", "disable-breadcrumbs", "automatic-breadcrumbs": multipleHandled() case "multiple-unhandled": multipleUnhandled() @@ -106,6 +116,8 @@ func main() { handledToUnhandled() case "nested-error": nestedHandledError() + case "maximum-breadcrumbs": + maximumBreadcrumbs() default: log.Println("Not a valid test flag: " + *test) os.Exit(1) @@ -309,6 +321,15 @@ func nestedHandledError() { } } +func maximumBreadcrumbs() { + bugsnag.Configure(bugsnag.Configuration{Synchronous: true}) + ctx := bugsnag.StartSession(context.Background()) + for i := 0; i < 10; i++ { + bugsnag.LeaveBreadcrumb(fmt.Sprintf("Crumb %v", i)) + } + bugsnag.Notify(fmt.Errorf("test error"), ctx) +} + func login(token string) error { val, err := checkValue(len(token) * -1) if err != nil { diff --git a/features/fixtures/autoconfigure/Dockerfile b/features/fixtures/autoconfigure/Dockerfile index 2d6db32..8285d65 100644 --- a/features/fixtures/autoconfigure/Dockerfile +++ b/features/fixtures/autoconfigure/Dockerfile @@ -11,6 +11,9 @@ WORKDIR /app/src/github.com/bugsnag/bugsnag-go/v2 # Ensure subsequent steps are re-run if the GO_VERSION variable changes ARG GO_VERSION +# Required for go 1.11 as modules are not enabled by default +ENV GO111MODULE="on" + # Get bugsnag dependencies using a conditional call to run go get or go install based on the go version RUN if [[ $(echo -e "1.11\n$GO_VERSION\n1.16" | sort -V | head -2 | tail -1) == "$GO_VERSION" ]]; then \ echo "Version is between 1.11 and 1.16, running go get"; \ diff --git a/features/fixtures/docker-compose.yml b/features/fixtures/docker-compose.yml index 6bc2f8d..1d22aba 100644 --- a/features/fixtures/docker-compose.yml +++ b/features/fixtures/docker-compose.yml @@ -13,6 +13,8 @@ services: - APP_VERSION - APP_TYPE - AUTO_CAPTURE_SESSIONS + - MAXIMUM_BREADCRUMBS + - ENABLED_BREADCRUMB_TYPES - HOSTNAME - NOTIFY_RELEASE_STAGES - RELEASE_STAGE diff --git a/features/fixtures/net_http/Dockerfile b/features/fixtures/net_http/Dockerfile index fb5e526..5afa0bf 100644 --- a/features/fixtures/net_http/Dockerfile +++ b/features/fixtures/net_http/Dockerfile @@ -13,6 +13,9 @@ WORKDIR /app/src/github.com/bugsnag/bugsnag-go/v2 # Ensure subsequent steps are re-run if the GO_VERSION variable changes ARG GO_VERSION +# Required for go 1.11 as modules are not enabled by default +ENV GO111MODULE="on" + # Get bugsnag dependencies using a conditional call to run go get or go install based on the go version RUN if [[ $(echo -e "1.11\n$GO_VERSION\n1.16" | sort -V | head -2 | tail -1) == "$GO_VERSION" ]]; then \ echo "Version is between 1.11 and 1.16, running go get"; \ diff --git a/features/handled.feature b/features/handled.feature index a7797a8..0a0f552 100644 --- a/features/handled.feature +++ b/features/handled.feature @@ -37,7 +37,7 @@ Scenario: Sending an event using a callback to modify report contents And the event "severityReason.type" equals "userCallbackSetSeverity" And the event "context" equals "nonfatal.go:14" And the "file" of stack frame 0 equals "main.go" - And stack frame 0 contains a local function spanning 245 to 253 + And stack frame 0 contains a local function spanning 257 to 265 And the "file" of stack frame 1 equals ">insertion<" And the "lineNumber" of stack frame 1 equals 0 @@ -50,7 +50,7 @@ Scenario: Marking an error as unhandled in a callback And the event "severityReason.type" equals "userCallbackSetSeverity" And the event "severityReason.unhandledOverridden" is true And the "file" of stack frame 0 equals "main.go" - And stack frame 0 contains a local function spanning 257 to 262 + And stack frame 0 contains a local function spanning 269 to 274 Scenario: Unwrapping the causes of a handled error When I run the go service "app" with the test case "nested-error" @@ -59,12 +59,12 @@ Scenario: Unwrapping the causes of a handled error And the event "unhandled" is false And the event "severity" equals "warning" And the event "exceptions.0.message" equals "terminate process" - And the "lineNumber" of stack frame 0 equals 295 + And the "lineNumber" of stack frame 0 equals 307 And the "file" of stack frame 0 equals "main.go" And the "method" of stack frame 0 equals "nestedHandledError" And the event "exceptions.1.message" equals "login failed" And the event "exceptions.1.stacktrace.0.file" equals "main.go" - And the event "exceptions.1.stacktrace.0.lineNumber" equals 315 + And the event "exceptions.1.stacktrace.0.lineNumber" equals 336 And the event "exceptions.2.message" equals "invalid token" And the event "exceptions.2.stacktrace.0.file" equals "main.go" - And the event "exceptions.2.stacktrace.0.lineNumber" equals 323 + And the event "exceptions.2.stacktrace.0.lineNumber" equals 344 diff --git a/v2/breadcrumb.go b/v2/breadcrumb.go new file mode 100644 index 0000000..a828ead --- /dev/null +++ b/v2/breadcrumb.go @@ -0,0 +1,167 @@ +package bugsnag + +import "time" + +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 maximumBreadcrumbsValue interface { + isValid() bool + trimBreadcrumbs(breadcrumbs []Breadcrumb) []Breadcrumb +} + +type MaximumBreadcrumbs int + +func (length MaximumBreadcrumbs) isValid() bool { + return length >= 0 && length <= 100 +} + +func (length MaximumBreadcrumbs) trimBreadcrumbs(breadcrumbs []Breadcrumb) []Breadcrumb { + if int(length) >= 0 && len(breadcrumbs) > int(length) { + return breadcrumbs[:int(length)] + } + return breadcrumbs +} + +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) leaveBreadcrumb(message string, configuration *Configuration, 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") + } + } + + if breadcrumbs.runBreadcrumbCallbacks(&breadcrumb) { + if breadcrumbs.breadcrumbs == nil { + breadcrumbs.breadcrumbs = []Breadcrumb{} + } + breadcrumbs.breadcrumbs = append([]Breadcrumb{breadcrumb}, breadcrumbs.breadcrumbs...) + if configuration.MaximumBreadcrumbs != nil { + breadcrumbs.breadcrumbs = configuration.MaximumBreadcrumbs.trimBreadcrumbs(breadcrumbs.breadcrumbs) + } + } +} + +func (configuration *Configuration) breadcrumbEnabled(breadcrumbType BreadcrumbType) bool { + if configuration.EnabledBreadcrumbTypes == nil { + return true + } + for _, enabled := range configuration.EnabledBreadcrumbTypes { + if enabled == breadcrumbType { + return true + } + } + return false +} + +func (breadcrumbs *breadcrumbState) leaveBugsnagStartBreadcrumb(configuration *Configuration) { + if configuration.breadcrumbEnabled(BreadcrumbTypeState) { + breadcrumbs.leaveBreadcrumb("Bugsnag loaded", configuration, BreadcrumbTypeState) + } +} + +func (breadcrumbs *breadcrumbState) leaveEventBreadcrumb(event *Event, configuration *Configuration) { + if event == nil { + return + } + if !configuration.breadcrumbEnabled(BreadcrumbTypeError) { + return + } + metadata := BreadcrumbMetaData{ + "errorClass": event.ErrorClass, + "message": event.Message, + "unhandled": event.Unhandled, + "severity": event.Severity.String, + } + breadcrumbs.leaveBreadcrumb(event.Error.Error(), configuration, BreadcrumbTypeError, metadata) +} diff --git a/v2/breadcrumb_test.go b/v2/breadcrumb_test.go new file mode 100644 index 0000000..d4bd409 --- /dev/null +++ b/v2/breadcrumb_test.go @@ -0,0 +1,325 @@ +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{EnabledBreadcrumbTypes: []bugsnag.BreadcrumbType{}}) + 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{EnabledBreadcrumbTypes: []bugsnag.BreadcrumbType{}}) + 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{EnabledBreadcrumbTypes: []bugsnag.BreadcrumbType{}}) + defer testServer.Close() + defaultMaximum := 50 + + 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) { + for _, customMaximum := range []int{-1, 0, 1, 99, 100, 101} { + testServer, reports, notifier := setupServer(bugsnag.Configuration{ + MaximumBreadcrumbs: bugsnag.MaximumBreadcrumbs(customMaximum), + EnabledBreadcrumbTypes: []bugsnag.BreadcrumbType{}, + }) + defer testServer.Close() + + breadcrumbsToAdd := 200 + for i := 1; i <= breadcrumbsToAdd; i++ { + notifier.LeaveBreadcrumb(fmt.Sprintf("breadcrumb%v", i)) + } + + notifier.Notify(fmt.Errorf("test error")) + breadcrumbs := getBreadcrumbs(reports) + + expectedBreadcrumbs := customMaximum + // The default value should be kept when the custom value is invalid + if customMaximum < 0 || customMaximum > 100 { + expectedBreadcrumbs = 50 + } + if len(breadcrumbs) != expectedBreadcrumbs { + t.Fatal("incorrect number of breadcrumbs, expected", expectedBreadcrumbs, "but found", len(breadcrumbs)) + } + for i := 0; i < expectedBreadcrumbs; i++ { + if breadcrumbs[i].Name != fmt.Sprintf("breadcrumb%v", breadcrumbsToAdd-i) { + t.Fatal("invalid breadcrumb at", i, "with custom maximum of", customMaximum) + } + } + } +} + +func TestBreadcrumbCallbacksAreReversed(t *testing.T) { + testServer, reports, notifier := setupServer(bugsnag.Configuration{EnabledBreadcrumbTypes: []bugsnag.BreadcrumbType{}}) + defer testServer.Close() + + callback1Called := false + callback2Called := false + notifier.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.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{EnabledBreadcrumbTypes: []bugsnag.BreadcrumbType{}}) + defer testServer.Close() + + callbackCalled := false + notifier.OnBreadcrumb(func(breadcrumb *bugsnag.Breadcrumb) bool { + t.Fatal("Callback should be canceled") + return true + }) + notifier.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{EnabledBreadcrumbTypes: []bugsnag.BreadcrumbType{}}) + 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{EnabledBreadcrumbTypes: []bugsnag.BreadcrumbType{}}) + 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 TestBugsnagStart(t *testing.T) { + testServer, reports, notifier := setupServer(bugsnag.Configuration{EnabledBreadcrumbTypes: []bugsnag.BreadcrumbType{bugsnag.BreadcrumbTypeState}}) + defer testServer.Close() + notifier.Notify(fmt.Errorf("test error")) + breadcrumbs := getBreadcrumbs(reports) + if len(breadcrumbs) != 1 { + t.Fatal("expected 1 breadcrumb", breadcrumbs) + } + if breadcrumbs[0].Name != "Bugsnag loaded" { + t.Fatal("expected the name to be 'Bugsnag loaded' but got", breadcrumbs[0].Name) + } + if breadcrumbs[0].Type != bugsnag.BreadcrumbTypeState { + t.Fatal("expected the type to be 'state' but got", breadcrumbs[0].Type) + } + if len(breadcrumbs[0].MetaData) != 0 { + t.Fatal("expected no metadata but got", breadcrumbs[0].MetaData) + } +} + +func TestBugsnagErrorBreadcrumb(t *testing.T) { + testServer, reports, notifier := setupServer(bugsnag.Configuration{EnabledBreadcrumbTypes: []bugsnag.BreadcrumbType{bugsnag.BreadcrumbTypeError}}) + defer testServer.Close() + notifier.Notify(fmt.Errorf("test error 1")) + breadcrumbs := getBreadcrumbs(reports) + if len(breadcrumbs) != 0 { + t.Fatal("expected 0 breadcrumbs", breadcrumbs) + } + notifier.Notify(fmt.Errorf("test error 2")) + breadcrumbs = getBreadcrumbs(reports) + if len(breadcrumbs) != 1 { + t.Fatal("expected 1 breadcrumb", breadcrumbs) + } + if breadcrumbs[0].Name != "test error 1" { + t.Fatal("expected the name to be 'test error 1' but got", breadcrumbs[0].Name) + } + if breadcrumbs[0].Type != bugsnag.BreadcrumbTypeError { + t.Fatal("expected the type to be 'error' but got", breadcrumbs[0].Type) + } + if len(breadcrumbs[0].MetaData) != 4 { + t.Fatal("expected 4 pieces of metadata metadata but got", breadcrumbs[0].MetaData) + } + if breadcrumbs[0].MetaData["errorClass"] != "*errors.errorString" { + t.Fatal("expected the errorClass to be '*errors.errorString' but got", breadcrumbs[0].MetaData["errorClass"]) + } + if breadcrumbs[0].MetaData["message"] != "test error 1" { + t.Fatal("expected the message to be 'test error 1' but got", breadcrumbs[0].MetaData["message"]) + } + if breadcrumbs[0].MetaData["unhandled"] != false { + t.Fatal("expected unhandled to be false") + } + if breadcrumbs[0].MetaData["severity"] != "info" { + t.Fatal("expected the severity to be 'info' bug got", breadcrumbs[0].MetaData["severity"]) + } +} + +func TestBreadcrumbsEnabledByDefault(t *testing.T) { + testServer, reports, notifier := setupServer(bugsnag.Configuration{}) + defer testServer.Close() + notifier.Notify(fmt.Errorf("test error 1")) + breadcrumbs := getBreadcrumbs(reports) + if len(breadcrumbs) != 1 { + t.Fatal("expected 1 breadcrumb", breadcrumbs) + } + notifier.Notify(fmt.Errorf("test error 2")) + breadcrumbs = getBreadcrumbs(reports) + if len(breadcrumbs) != 2 { + t.Fatal("expected 2 breadcrumb", breadcrumbs) + } + if breadcrumbs[0].Name != "test error 1" { + t.Fatal("expected the name to be 'test error 1' but got", breadcrumbs[0].Name) + } + if breadcrumbs[1].Name != "Bugsnag loaded" { + t.Fatal("expected the name to be 'Bugsnag loaded' but got", breadcrumbs[1].Name) + } +} + +func TestSendCleanMetadata(t *testing.T) { + testServer, reports, notifier := setupServer(bugsnag.Configuration{EnabledBreadcrumbTypes: []bugsnag.BreadcrumbType{}}) + 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 631d2e9..8063671 100644 --- a/v2/bugsnag.go +++ b/v2/bugsnag.go @@ -26,6 +26,7 @@ const Version = "2.4.0" var panicHandlerOnce sync.Once var sessionTrackerOnce sync.Once var readEnvConfigOnce sync.Once +var startBugsnagBreadcrumbOnce sync.Once var middleware middlewareStack // Config is the configuration for the default bugsnag notifier. @@ -36,7 +37,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 @@ -51,6 +52,10 @@ func Configure(config Configuration) { // Only do once in case the user overrides the default panichandler, and // configures multiple times. panicHandlerOnce.Do(Config.PanicHandler) + // Trigger the busnag start breadcrumb + startBugsnagBreadcrumbOnce.Do(func() { + defaultNotifier.breadcrumbState.leaveBugsnagStartBreadcrumb(defaultNotifier.Config) + }) } // StartSession creates new context from the context.Context instance with @@ -212,6 +217,18 @@ 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...) +} + +// OnBreadcrumb 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.OnBreadcrumb(callback) +} + // 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. @@ -241,18 +258,20 @@ func init() { Notify: "https://notify.bugsnag.com", Sessions: "https://sessions.bugsnag.com", }, - Hostname: device.GetHostname(), - AppType: "", - AppVersion: "", - AutoCaptureSessions: true, - ReleaseStage: "", - ParamsFilters: []string{"password", "secret", "authorization", "cookie", "access_token"}, - SourceRoot: sourceRoot, - ProjectPackages: []string{"main*"}, - NotifyReleaseStages: nil, - Logger: log.New(os.Stdout, log.Prefix(), log.Flags()), - PanicHandler: defaultPanicHandler, - Transport: http.DefaultTransport, + Hostname: device.GetHostname(), + AppType: "", + AppVersion: "", + AutoCaptureSessions: true, + ReleaseStage: "", + ParamsFilters: []string{"password", "secret", "authorization", "cookie", "access_token"}, + SourceRoot: sourceRoot, + ProjectPackages: []string{"main*"}, + NotifyReleaseStages: nil, + Logger: log.New(os.Stdout, log.Prefix(), log.Flags()), + PanicHandler: defaultPanicHandler, + Transport: http.DefaultTransport, + MaximumBreadcrumbs: MaximumBreadcrumbs(50), + EnabledBreadcrumbTypes: nil, flushSessionsOnRepanic: true, }) diff --git a/v2/configuration.go b/v2/configuration.go index 08ecb7e..f34aeb7 100644 --- a/v2/configuration.go +++ b/v2/configuration.go @@ -2,6 +2,7 @@ package bugsnag import ( "context" + "fmt" "log" "net/http" "os" @@ -108,6 +109,13 @@ 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 50. + MaximumBreadcrumbs maximumBreadcrumbsValue + + // Breacrumbs that can be automatically added. If nil then all breadcrumbs will be added. + EnabledBreadcrumbTypes []BreadcrumbType + // 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 +179,16 @@ func (config *Configuration) update(other *Configuration) *Configuration { config.MainContext = other.MainContext publisher.setMainProgramContext(other.MainContext) } + if other.MaximumBreadcrumbs != nil { + if other.MaximumBreadcrumbs.isValid() { + config.MaximumBreadcrumbs = other.MaximumBreadcrumbs + } else { + fmt.Println("Invalid maximum breadcrumbs of", other.MaximumBreadcrumbs) + } + } + if other.EnabledBreadcrumbTypes != nil { + config.EnabledBreadcrumbTypes = other.EnabledBreadcrumbTypes + } if other.AutoCaptureSessions != nil { config.AutoCaptureSessions = other.AutoCaptureSessions diff --git a/v2/event.go b/v2/event.go index 742084e..92003ae 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 876dcc8..72e89ea 100644 --- a/v2/notifier.go +++ b/v2/notifier.go @@ -8,8 +8,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. @@ -24,10 +25,13 @@ func New(rawData ...interface{}) *Notifier { } } - return &Notifier{ - Config: config, - RawData: rawData, + notifier := Notifier{ + Config: config, + RawData: rawData, + breadcrumbState: breadcrumbState{}, } + notifier.breadcrumbState.leaveBugsnagStartBreadcrumb(notifier.Config) + return ¬ifier } // FlushSessionsOnRepanic takes a boolean that indicates whether sessions @@ -72,6 +76,7 @@ func (notifier *Notifier) NotifySync(err error, sync bool, rawData ...interface{ // Never block, start throwing away errors if we have too many. e := middleware.Run(event, config, func() error { + notifier.breadcrumbState.leaveEventBreadcrumb(event, notifier.Config) return publisher.publishReport(&payload{event, config}) }) @@ -116,6 +121,18 @@ 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{}) { + notifier.breadcrumbState.leaveBreadcrumb(message, notifier.Config, rawData...) +} + +// OnBreadcrumb adds a callback to be run before a breadcrumb is added to this notifier. +// If false is returned, the breadcrumb will be discarded. These callbacks are run in reverse order. +func (notifier *Notifier) OnBreadcrumb(callback func(breadcrumb *Breadcrumb) bool) { + notifier.breadcrumbState.onBreadcrumb(callback) +} + 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 be2234c..4e46b7e 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 e7e145a..c49c390 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"`