diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 7d24788e5a1..99affc01406 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -39,6 +39,7 @@ "@types/react-color": "3.0.1", "@types/react-select": "3.0.8", "@types/react-table": "7.0.12", + "@sentry/browser": "5.25.0", "@types/slate": "0.47.1", "@types/slate-react": "0.22.5", "classnames": "2.2.6", diff --git a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.test.tsx b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.test.tsx new file mode 100644 index 00000000000..a6f8368633f --- /dev/null +++ b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.test.tsx @@ -0,0 +1,36 @@ +import React, { FC } from 'react'; +import { render, screen } from '@testing-library/react'; +import { ErrorBoundary } from './ErrorBoundary'; +import { captureException } from '@sentry/browser'; + +jest.mock('@sentry/browser'); + +const ErrorThrower: FC<{ error: Error }> = ({ error }) => { + throw error; +}; + +describe('ErrorBoundary', () => { + it('should catch error and report it to sentry, including react component stack in context', async () => { + const problem = new Error('things went terribly wrong'); + render( + + {({ error }) => { + if (!error) { + return ; + } else { + return

{error.message}

; + } + }} +
+ ); + + await screen.findByText(problem.message); + expect(captureException).toHaveBeenCalledTimes(1); + const [error, context] = (captureException as jest.Mock).mock.calls[0]; + expect(error).toBe(problem); + expect(context).toHaveProperty('contexts'); + expect(context.contexts).toHaveProperty('react'); + expect(context.contexts.react).toHaveProperty('componentStack'); + expect(context.contexts.react.componentStack).toMatch(/^\s+at ErrorThrower (.*)\s+at ErrorBoundary (.*)\s*$/); + }); +}); diff --git a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx index 4ed74da0c41..bcdfcd3186f 100644 --- a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx +++ b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -1,4 +1,5 @@ import React, { PureComponent, ReactNode } from 'react'; +import { captureException } from '@sentry/browser'; import { Alert } from '../Alert/Alert'; import { ErrorWithStack } from './ErrorWithStack'; @@ -27,6 +28,7 @@ export class ErrorBoundary extends PureComponent { }; componentDidCatch(error: Error, errorInfo: ErrorInfo) { + captureException(error, { contexts: { react: { componentStack: errorInfo.componentStack } } }); this.setState({ error: error, errorInfo: errorInfo, diff --git a/pkg/api/frontend_logging.go b/pkg/api/frontend_logging.go index 845535ca09c..86cb760bfb0 100644 --- a/pkg/api/frontend_logging.go +++ b/pkg/api/frontend_logging.go @@ -47,6 +47,18 @@ func (exception *frontendSentryException) FmtStacktraces() string { return strings.Join(stacktraces, "\n\n") } +func addEventContextToLogContext(rootPrefix string, logCtx log15.Ctx, eventCtx map[string]interface{}) { + for key, element := range eventCtx { + prefix := fmt.Sprintf("%s_%s", rootPrefix, key) + switch v := element.(type) { + case map[string]interface{}: + addEventContextToLogContext(prefix, logCtx, v) + default: + logCtx[prefix] = fmt.Sprintf("%v", v) + } + } +} + func (event *frontendSentryEvent) ToLogContext() log15.Ctx { var ctx = make(log15.Ctx) ctx["url"] = event.Request.URL @@ -56,6 +68,7 @@ func (event *frontendSentryEvent) ToLogContext() log15.Ctx { if event.Exception != nil { ctx["stacktrace"] = event.Exception.FmtStacktraces() } + addEventContextToLogContext("context", ctx, event.Contexts) if len(event.User.Email) > 0 { ctx["user_email"] = event.User.Email ctx["user_id"] = event.User.ID diff --git a/pkg/api/frontend_logging_test.go b/pkg/api/frontend_logging_test.go index 6b6f32b96d9..fb0620d5d58 100644 --- a/pkg/api/frontend_logging_test.go +++ b/pkg/api/frontend_logging_test.go @@ -102,6 +102,7 @@ func TestFrontendLoggingEndpoint(t *testing.T) { assertContextContains(t, logs[0], "stacktrace", `UserError: Please replace user and try again at foofn (foo.js:123:23) at barfn (bar.js:113:231)`) + assert.NotContains(t, logs[0].Ctx, "context") }) messageEvent := frontendSentryEvent{ @@ -127,9 +128,37 @@ func TestFrontendLoggingEndpoint(t *testing.T) { assertContextContains(t, logs[0], "event_id", messageEvent.EventID) assertContextContains(t, logs[0], "original_timestamp", messageEvent.Timestamp) assert.NotContains(t, logs[0].Ctx, "stacktrace") + assert.NotContains(t, logs[0].Ctx, "context") assertContextContains(t, logs[0], "user_email", user.Email) assertContextContains(t, logs[0], "user_id", user.ID) }) + + eventWithContext := frontendSentryEvent{ + &sentry.Event{ + EventID: "123", + Level: sentry.LevelInfo, + Request: &request, + Timestamp: ts, + Message: "hello world", + User: user, + Contexts: map[string]interface{}{ + "foo": map[string]interface{}{ + "one": "two", + "three": 4, + }, + "bar": "baz", + }, + }, + nil, + } + + logSentryEventScenario(t, "Should log event context", eventWithContext, func(sc *scenarioContext, logs []*log.Record) { + assert.Equal(t, 200, sc.resp.Code) + assert.Len(t, logs, 1) + assertContextContains(t, logs[0], "context_foo_one", "two") + assertContextContains(t, logs[0], "context_foo_three", "4") + assertContextContains(t, logs[0], "context_bar", "baz") + }) }) }