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")
+ })
})
}