Integrate Event-Based Telemetry in KibanaErrorBoundary (#169895)

## Summary

Part of https://github.com/elastic/kibana-team/issues/646
Depends on https://github.com/elastic/kibana/pull/169324

Implements telemetry for fatal errors caught by KibanaErrorBoundary in:
-
`packages/core/application/core-application-browser-internal/src/ui/app_router.tsx`
- `packages/kbn-shared-ux-utility/src/with_suspense.tsx` [*]
- `packages/react/kibana_context/render/render_provider.tsx` [*]
-
`src/plugins/management/public/components/management_app/management_router.tsx`
-
`x-pack/plugins/observability_shared/public/components/page_template/page_template.tsx`
- `x-pack/plugins/security_solution/public/app/app.tsx`

[*] The changes made to these allowed the `analytics` dependency to be
provided optionally, to avoid a breaking API change for maintainers.

## Logging screenshot
You can trigger a fatal error in the new error boundary component in
most places in Kibana by adding a TypeError to a React component:
`<p>{breakHere()}</p>`
<img width="1586" alt="fatal error telemetry console log"
src="97f973ac-bb25-41f2-bfe2-547a23f2f450">

## Telemetry work info
Dashboard:
<img width="1382" alt="image"
src="4fe5353a-61ba-405a-ac18-0dd6a044c182">
Discover:
<img width="1331" alt="image"
src="2161b552-c441-4b7c-adef-25896147c08a">

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Tim Sullivan 2023-11-03 12:55:00 -07:00 committed by GitHub
parent 8716f65922
commit 704e080c93
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
72 changed files with 1384 additions and 119 deletions

View file

@ -53,6 +53,7 @@ describe('ApplicationService', () => {
overlays: overlayServiceMock.createStartContract(),
theme: themeServiceMock.createStartContract(),
customBranding: customBrandingServiceMock.createStartContract(),
analytics: analyticsServiceMock.createAnalyticsServiceStart(),
};
service = new ApplicationService();
});

View file

@ -10,8 +10,10 @@ import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { createMemoryHistory, History, createHashHistory } from 'history';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { AppStatus } from '@kbn/core-application-browser';
import { AppRouter, AppNotFound } from '../src/ui';
import { MockedMounterMap, MockedMounterTuple } from '../src/test_helpers/test_types';
import { createRenderer, createAppMounter, getUnmounter } from './utils';
@ -42,9 +44,12 @@ describe('AppRouter', () => {
);
};
const mockAnalytics = analyticsServiceMock.createAnalyticsServiceStart();
const createMountersRenderer = () =>
createRenderer(
<AppRouter
analytics={mockAnalytics}
history={globalHistory}
mounters={mockMountersToMounters()}
appStatuses$={mountersToAppStatus$()}

View file

@ -2,6 +2,20 @@
exports[`#start() getComponent returns renderable JSX tree 1`] = `
<AppRouter
analytics={
Object {
"optIn": [MockFunction],
"reportEvent": [MockFunction],
"telemetryCounter$": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
}
}
appStatuses$={
AnonymousSubject {
"closed": false,

View file

@ -65,6 +65,7 @@ describe('#setup()', () => {
overlays: overlayServiceMock.createStartContract(),
theme: themeServiceMock.createStartContract(),
customBranding: customBrandingServiceMock.createStartContract(),
analytics: analyticsServiceMock.createAnalyticsServiceStart(),
};
service = new ApplicationService();
});
@ -515,6 +516,7 @@ describe('#start()', () => {
overlays: overlayServiceMock.createStartContract(),
theme: themeServiceMock.createStartContract(),
customBranding: customBrandingServiceMock.createStartContract(),
analytics: analyticsServiceMock.createAnalyticsServiceStart(),
};
service = new ApplicationService();
});
@ -1227,6 +1229,7 @@ describe('#stop()', () => {
overlays: overlayServiceMock.createStartContract(),
theme: themeServiceMock.createStartContract(),
customBranding: customBrandingServiceMock.createStartContract(),
analytics: analyticsServiceMock.createAnalyticsServiceStart(),
};
service = new ApplicationService();
});

View file

@ -17,7 +17,7 @@ import type { HttpSetup, HttpStart } from '@kbn/core-http-browser';
import type { Capabilities } from '@kbn/core-capabilities-common';
import type { MountPoint } from '@kbn/core-mount-utils-browser';
import type { OverlayStart } from '@kbn/core-overlays-browser';
import type { AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
import type { AnalyticsServiceSetup, AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import type {
App,
AppDeepLink,
@ -55,6 +55,7 @@ export interface SetupDeps {
export interface StartDeps {
http: HttpStart;
analytics: AnalyticsServiceStart;
theme: ThemeServiceStart;
overlays: OverlayStart;
customBranding: CustomBrandingStart;
@ -225,6 +226,7 @@ export class ApplicationService {
}
public async start({
analytics,
http,
overlays,
theme,
@ -364,6 +366,7 @@ export class ApplicationService {
}
return (
<AppRouter
analytics={analytics}
history={this.history}
theme$={theme.theme$}
mounters={availableMounters}

View file

@ -11,6 +11,7 @@ import { act } from 'react-dom/test-utils';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { type AppMountParameters, AppStatus } from '@kbn/core-application-browser';
import { KibanaErrorBoundary, KibanaErrorBoundaryProvider } from '@kbn/shared-ux-error-boundary';
import { AppContainer } from './app_container';
@ -211,19 +212,20 @@ describe('AppContainer', () => {
it('should call setIsMounting(false) if mounting throws', async () => {
const [waitPromise, resolvePromise] = createResolver();
const analytics = analyticsServiceMock.createAnalyticsServiceStart();
const mounter = {
appBasePath: '/base-path/some-route',
appRoute: '/some-route',
unmountBeforeMounting: false,
exactRoute: false,
mount: async ({ element }: AppMountParameters) => {
mount: async () => {
await waitPromise;
throw new Error(`Mounting failed!`);
},
};
const wrapper = mountWithIntl(
<KibanaErrorBoundaryProvider>
<KibanaErrorBoundaryProvider analytics={analytics}>
<KibanaErrorBoundary>
<AppContainer
appPath={`/app/${appId}`}

View file

@ -17,11 +17,13 @@ import type { CoreTheme } from '@kbn/core-theme-browser';
import type { MountPoint } from '@kbn/core-mount-utils-browser';
import { type AppLeaveHandler, AppStatus } from '@kbn/core-application-browser';
import { KibanaErrorBoundary, KibanaErrorBoundaryProvider } from '@kbn/shared-ux-error-boundary';
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import type { Mounter } from '../types';
import { AppContainer } from './app_container';
import { CoreScopedHistory } from '../scoped_history';
interface Props {
analytics: AnalyticsServiceStart;
mounters: Map<string, Mounter>;
history: History;
theme$: Observable<CoreTheme>;
@ -38,6 +40,7 @@ interface Params {
export const AppRouter: FunctionComponent<Props> = ({
history,
analytics,
mounters,
theme$,
setAppLeaveHandler,
@ -55,7 +58,7 @@ export const AppRouter: FunctionComponent<Props> = ({
const showPlainSpinner = useObservable(hasCustomBranding$ ?? EMPTY, false);
return (
<KibanaErrorBoundaryProvider>
<KibanaErrorBoundaryProvider analytics={analytics}>
<KibanaErrorBoundary>
<Router history={history}>
<Routes>

View file

@ -4,6 +4,13 @@ exports[`#add() deletes all children of rootDomElement and renders <FatalErrorSc
Array [
Array [
<KibanaRootContextProvider
analytics={
Object {
"optIn": [MockFunction],
"reportEvent": [MockFunction],
"telemetryCounter$": Rx.Observable,
}
}
globalStyles={true}
i18n={
Object {

View file

@ -13,6 +13,7 @@ expect.addSnapshotSerializer({
print: () => `Rx.Observable`,
});
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { mockRender } from './fatal_errors_service.test.mocks';
import { injectedMetadataServiceMock } from '@kbn/core-injected-metadata-browser-mocks';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
@ -22,6 +23,7 @@ import { FatalErrorsService } from './fatal_errors_service';
function setupService() {
const rootDomElement = document.createElement('div');
const analytics = analyticsServiceMock.createAnalyticsServiceStart();
const injectedMetadata = injectedMetadataServiceMock.createSetupContract();
const theme = themeServiceMock.createSetupContract();
@ -39,7 +41,7 @@ function setupService() {
rootDomElement,
injectedMetadata,
stopCoreSystem,
fatalErrors: fatalErrorsService.setup({ injectedMetadata, i18n, theme }),
fatalErrors: fatalErrorsService.setup({ analytics, injectedMetadata, i18n, theme }),
};
}

View file

@ -10,6 +10,7 @@ import React from 'react';
import { render } from 'react-dom';
import { ReplaySubject, first, tap } from 'rxjs';
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import type { InternalInjectedMetadataSetup } from '@kbn/core-injected-metadata-browser-internal';
import type { ThemeServiceSetup } from '@kbn/core-theme-browser';
import type { I18nStart } from '@kbn/core-i18n-browser';
@ -20,6 +21,7 @@ import { getErrorInfo } from './get_error_info';
/** @internal */
export interface FatalErrorsServiceSetupDeps {
analytics: AnalyticsServiceStart;
i18n: I18nStart;
theme: ThemeServiceSetup;
injectedMetadata: InternalInjectedMetadataSetup;
@ -86,7 +88,7 @@ export class FatalErrorsService {
return fatalErrors;
}
private renderError({ i18n, theme, injectedMetadata }: FatalErrorsServiceSetupDeps) {
private renderError({ analytics, i18n, theme, injectedMetadata }: FatalErrorsServiceSetupDeps) {
// delete all content in the rootDomElement
this.rootDomElement.textContent = '';
@ -95,7 +97,12 @@ export class FatalErrorsService {
this.rootDomElement.appendChild(container);
render(
<KibanaRootContextProvider i18n={i18n} theme={theme} globalStyles={true}>
<KibanaRootContextProvider
analytics={analytics}
i18n={i18n}
theme={theme}
globalStyles={true}
>
<FatalErrorsScreen
buildNumber={injectedMetadata.getKibanaBuildNumber()}
kibanaVersion={injectedMetadata.getKibanaVersion()}

View file

@ -23,6 +23,8 @@
"@kbn/test-subj-selector",
"@kbn/test-jest-helpers",
"@kbn/react-kibana-context-root",
"@kbn/core-analytics-browser-mocks",
"@kbn/core-analytics-browser",
],
"exclude": [
"target/**/*",

View file

@ -79,6 +79,7 @@ export class NotificationsService {
eventReporter,
i18n: i18nDep,
overlays,
analytics,
theme,
targetDomElement: toastsContainer,
}),
@ -87,6 +88,7 @@ export class NotificationsService {
title,
error,
openModal: overlays.openModal,
analytics,
i18n: i18nDep,
theme,
}),

View file

@ -4,6 +4,20 @@ exports[`#start() renders the GlobalToastList into the targetDomElement param 1`
Array [
Array [
<KibanaRenderContextProvider
analytics={
Object {
"optIn": [MockFunction],
"reportEvent": [MockFunction],
"telemetryCounter$": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
}
}
i18n={
Object {
"Context": [Function],

View file

@ -10,6 +10,7 @@ import { shallow } from 'enzyme';
import React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { ErrorToast } from './error_toast';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { i18nServiceMock } from '@kbn/core-i18n-browser-mocks';
@ -21,6 +22,7 @@ interface ErrorToastProps {
}
let openModal: jest.Mock;
const mockAnalytics = analyticsServiceMock.createAnalyticsServiceStart();
const mockTheme = themeServiceMock.createStartContract();
const mockI18n = i18nServiceMock.createStartContract();
@ -33,6 +35,7 @@ function render(props: ErrorToastProps = {}) {
error={props.error || new Error('error message')}
title={props.title || 'An error occured'}
toastMessage={props.toastMessage || 'This is the toast message'}
analytics={mockAnalytics}
i18n={mockI18n}
theme={mockTheme}
/>

View file

@ -20,6 +20,7 @@ import {
} from '@elastic/eui';
import { EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import type { I18nStart } from '@kbn/core-i18n-browser';
import type { OverlayStart } from '@kbn/core-overlays-browser';
import { ThemeServiceStart } from '@kbn/core-theme-browser';
@ -30,6 +31,7 @@ interface ErrorToastProps {
error: Error;
toastMessage: string;
openModal: OverlayStart['openModal'];
analytics: AnalyticsServiceStart;
i18n: I18nStart;
theme: ThemeServiceStart;
}
@ -55,9 +57,10 @@ export function showErrorDialog({
title,
error,
openModal,
analytics,
i18n,
theme,
}: Pick<ErrorToastProps, 'error' | 'title' | 'openModal' | 'i18n' | 'theme'>) {
}: Pick<ErrorToastProps, 'error' | 'title' | 'openModal' | 'analytics' | 'i18n' | 'theme'>) {
let text = '';
if (isRequestError(error)) {
@ -71,7 +74,7 @@ export function showErrorDialog({
const modal = openModal(
mount(
<KibanaRenderContextProvider i18n={i18n} theme={theme}>
<KibanaRenderContextProvider analytics={analytics} i18n={i18n} theme={theme}>
<EuiModalHeader>
<EuiModalHeaderTitle>{title}</EuiModalHeaderTitle>
</EuiModalHeader>
@ -104,6 +107,7 @@ export function ErrorToast({
error,
toastMessage,
openModal,
analytics,
i18n,
theme,
}: ErrorToastProps) {
@ -115,7 +119,7 @@ export function ErrorToast({
size="s"
color="danger"
data-test-subj="errorToastBtn"
onClick={() => showErrorDialog({ title, error, openModal, i18n, theme })}
onClick={() => showErrorDialog({ title, error, openModal, analytics, i18n, theme })}
>
<FormattedMessage
id="core.toasts.errorToast.seeFullError"

View file

@ -10,6 +10,7 @@ import React from 'react';
import * as Rx from 'rxjs';
import { omitBy, isUndefined } from 'lodash';
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import type { I18nStart } from '@kbn/core-i18n-browser';
import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import type { OverlayStart } from '@kbn/core-overlays-browser';
@ -44,6 +45,7 @@ export class ToastsApi implements IToasts {
private uiSettings: IUiSettingsClient;
private overlays?: OverlayStart;
private analytics?: AnalyticsServiceStart;
private i18n?: I18nStart;
private theme?: ThemeServiceStart;
@ -187,6 +189,7 @@ export class ToastsApi implements IToasts {
error={error}
title={options.title}
toastMessage={message}
analytics={this.analytics!}
i18n={this.i18n!}
theme={this.theme!}
/>

View file

@ -47,6 +47,7 @@ describe('#start()', () => {
expect(mockReactDomRender).not.toHaveBeenCalled();
toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() });
toasts.start({
analytics: mockAnalytics,
i18n: mockI18n,
theme: mockTheme,
targetDomElement,
@ -65,6 +66,7 @@ describe('#start()', () => {
).toBeInstanceOf(ToastsApi);
expect(
toasts.start({
analytics: mockAnalytics,
i18n: mockI18n,
theme: mockTheme,
targetDomElement,
@ -83,6 +85,7 @@ describe('#stop()', () => {
toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() });
toasts.start({
analytics: mockAnalytics,
i18n: mockI18n,
theme: mockTheme,
targetDomElement,
@ -108,6 +111,7 @@ describe('#stop()', () => {
toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() });
toasts.start({
analytics: mockAnalytics,
i18n: mockI18n,
theme: mockTheme,
targetDomElement,

View file

@ -9,6 +9,7 @@
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import type { ThemeServiceStart } from '@kbn/core-theme-browser';
import type { I18nStart } from '@kbn/core-i18n-browser';
import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
@ -23,6 +24,7 @@ interface SetupDeps {
}
interface StartDeps {
analytics: AnalyticsServiceStart;
i18n: I18nStart;
overlays: OverlayStart;
theme: ThemeServiceStart;
@ -39,12 +41,12 @@ export class ToastsService {
return this.api!;
}
public start({ eventReporter, i18n, overlays, theme, targetDomElement }: StartDeps) {
public start({ eventReporter, analytics, i18n, overlays, theme, targetDomElement }: StartDeps) {
this.api!.start({ overlays, i18n, theme });
this.targetDomElement = targetDomElement;
render(
<KibanaRenderContextProvider i18n={i18n} theme={theme}>
<KibanaRenderContextProvider analytics={analytics} i18n={i18n} theme={theme}>
<GlobalToastList
dismissToast={(toastId: string) => this.api!.remove(toastId)}
toasts$={this.api!.get$()}

View file

@ -12,6 +12,20 @@ exports[`FlyoutService openFlyout() renders a flyout to the DOM 1`] = `
Array [
Array [
<KibanaRenderContextProvider
analytics={
Object {
"optIn": [MockFunction],
"reportEvent": [MockFunction],
"telemetryCounter$": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
}
}
i18n={
Object {
"Context": [MockFunction],
@ -45,13 +59,42 @@ exports[`FlyoutService openFlyout() with a currently active flyout replaces the
Array [
Array [
<KibanaRenderContextProvider
analytics={
Object {
"optIn": [MockFunction],
"reportEvent": [MockFunction],
"telemetryCounter$": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
}
}
i18n={
Object {
"Context": [MockFunction] {
"calls": Array [
Array [
Object {
"children": <KibanaErrorBoundaryProvider>
"children": <KibanaErrorBoundaryProvider
analytics={
Object {
"optIn": [MockFunction],
"reportEvent": [MockFunction],
"telemetryCounter$": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
}
}
>
<KibanaErrorBoundary>
<EuiFlyout
onClose={[Function]}
@ -70,7 +113,22 @@ Array [
"results": Array [
Object {
"type": "return",
"value": <KibanaErrorBoundaryProvider>
"value": <KibanaErrorBoundaryProvider
analytics={
Object {
"optIn": [MockFunction],
"reportEvent": [MockFunction],
"telemetryCounter$": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
}
}
>
<KibanaErrorBoundary>
<EuiFlyout
onClose={[Function]}
@ -108,13 +166,42 @@ Array [
],
Array [
<KibanaRenderContextProvider
analytics={
Object {
"optIn": [MockFunction],
"reportEvent": [MockFunction],
"telemetryCounter$": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
}
}
i18n={
Object {
"Context": [MockFunction] {
"calls": Array [
Array [
Object {
"children": <KibanaErrorBoundaryProvider>
"children": <KibanaErrorBoundaryProvider
analytics={
Object {
"optIn": [MockFunction],
"reportEvent": [MockFunction],
"telemetryCounter$": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
}
}
>
<KibanaErrorBoundary>
<EuiFlyout
onClose={[Function]}
@ -133,7 +220,22 @@ Array [
"results": Array [
Object {
"type": "return",
"value": <KibanaErrorBoundaryProvider>
"value": <KibanaErrorBoundaryProvider
analytics={
Object {
"optIn": [MockFunction],
"reportEvent": [MockFunction],
"telemetryCounter$": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
}
}
>
<KibanaErrorBoundary>
<EuiFlyout
onClose={[Function]}

View file

@ -9,12 +9,14 @@
import { mockReactDomRender, mockReactDomUnmount } from '../overlay.test.mocks';
import { mount } from 'enzyme';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { i18nServiceMock } from '@kbn/core-i18n-browser-mocks';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { FlyoutService } from './flyout_service';
import type { OverlayRef } from '@kbn/core-mount-utils-browser';
import type { OverlayFlyoutStart } from '@kbn/core-overlays-browser';
const analyticsMock = analyticsServiceMock.createAnalyticsServiceStart();
const i18nMock = i18nServiceMock.createStartContract();
const themeMock = themeServiceMock.createStartContract();
@ -33,6 +35,7 @@ const mountText = (text: string) => (container: HTMLElement) => {
const getServiceStart = () => {
const service = new FlyoutService();
return service.start({
analytics: analyticsMock,
i18n: i18nMock,
theme: themeMock,
targetDomElement: document.createElement('div'),

View file

@ -12,6 +12,7 @@ import { EuiFlyout } from '@elastic/eui';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Subject } from 'rxjs';
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import type { ThemeServiceStart } from '@kbn/core-theme-browser';
import type { I18nStart } from '@kbn/core-i18n-browser';
import type { MountPoint, OverlayRef } from '@kbn/core-mount-utils-browser';
@ -60,6 +61,7 @@ class FlyoutRef implements OverlayRef {
}
interface StartDeps {
analytics: AnalyticsServiceStart;
i18n: I18nStart;
theme: ThemeServiceStart;
targetDomElement: Element;
@ -70,7 +72,7 @@ export class FlyoutService {
private activeFlyout: FlyoutRef | null = null;
private targetDomElement: Element | null = null;
public start({ i18n, theme, targetDomElement }: StartDeps): OverlayFlyoutStart {
public start({ analytics, i18n, theme, targetDomElement }: StartDeps): OverlayFlyoutStart {
this.targetDomElement = targetDomElement;
return {
@ -101,7 +103,7 @@ export class FlyoutService {
};
render(
<KibanaRenderContextProvider i18n={i18n} theme={theme}>
<KibanaRenderContextProvider analytics={analytics} i18n={i18n} theme={theme}>
<EuiFlyout {...options} onClose={onCloseFlyout}>
<MountWrapper mount={mount} className="kbnOverlayMountWrapper" />
</EuiFlyout>

View file

@ -10,6 +10,7 @@ import { mockReactDomRender, mockReactDomUnmount } from '../overlay.test.mocks';
import React from 'react';
import { mount } from 'enzyme';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { i18nServiceMock } from '@kbn/core-i18n-browser-mocks';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { ModalService } from './modal_service';
@ -17,6 +18,7 @@ import type { OverlayModalStart } from '@kbn/core-overlays-browser';
import { mountReactNode } from '@kbn/core-mount-utils-browser-internal';
import type { OverlayRef } from '@kbn/core-mount-utils-browser';
const analyticsMock = analyticsServiceMock.createAnalyticsServiceStart();
const i18nMock = i18nServiceMock.createStartContract();
const themeMock = themeServiceMock.createStartContract();
@ -28,6 +30,7 @@ beforeEach(() => {
const getServiceStart = () => {
const service = new ModalService();
return service.start({
analytics: analyticsMock,
i18n: i18nMock,
theme: themeMock,
targetDomElement: document.createElement('div'),

View file

@ -13,6 +13,7 @@ import { EuiModal, EuiConfirmModal } from '@elastic/eui';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Subject } from 'rxjs';
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import type { ThemeServiceStart } from '@kbn/core-theme-browser';
import type { I18nStart } from '@kbn/core-i18n-browser';
import type { MountPoint, OverlayRef } from '@kbn/core-mount-utils-browser';
@ -56,6 +57,7 @@ class ModalRef implements OverlayRef {
interface StartDeps {
i18n: I18nStart;
theme: ThemeServiceStart;
analytics: AnalyticsServiceStart;
targetDomElement: Element;
}
@ -64,7 +66,7 @@ export class ModalService {
private activeModal: ModalRef | null = null;
private targetDomElement: Element | null = null;
public start({ i18n, theme, targetDomElement }: StartDeps): OverlayModalStart {
public start({ analytics, i18n, theme, targetDomElement }: StartDeps): OverlayModalStart {
this.targetDomElement = targetDomElement;
return {
@ -87,7 +89,7 @@ export class ModalService {
this.activeModal = modal;
render(
<KibanaRenderContextProvider i18n={i18n} theme={theme}>
<KibanaRenderContextProvider analytics={analytics} i18n={i18n} theme={theme}>
<EuiModal {...options} onClose={() => modal.close()}>
<MountWrapper mount={mount} className="kbnOverlayMountWrapper" />
</EuiModal>
@ -147,7 +149,7 @@ export class ModalService {
};
render(
<KibanaRenderContextProvider i18n={i18n} theme={theme}>
<KibanaRenderContextProvider analytics={analytics} i18n={i18n} theme={theme}>
<EuiConfirmModal {...props} />
</KibanaRenderContextProvider>,
targetDomElement

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import type { ThemeServiceStart } from '@kbn/core-theme-browser';
import type { I18nStart } from '@kbn/core-i18n-browser';
import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
@ -15,6 +16,7 @@ import { FlyoutService } from './flyout';
import { ModalService } from './modal';
interface StartDeps {
analytics: AnalyticsServiceStart;
i18n: I18nStart;
theme: ThemeServiceStart;
targetDomElement: HTMLElement;
@ -27,16 +29,26 @@ export class OverlayService {
private modalService = new ModalService();
private flyoutService = new FlyoutService();
public start({ i18n, targetDomElement, uiSettings, theme }: StartDeps): OverlayStart {
public start({ analytics, i18n, targetDomElement, uiSettings, theme }: StartDeps): OverlayStart {
const flyoutElement = document.createElement('div');
targetDomElement.appendChild(flyoutElement);
const flyouts = this.flyoutService.start({ i18n, theme, targetDomElement: flyoutElement });
const flyouts = this.flyoutService.start({
analytics,
i18n,
theme,
targetDomElement: flyoutElement,
});
const banners = this.bannersService.start({ i18n, uiSettings });
const modalElement = document.createElement('div');
targetDomElement.appendChild(modalElement);
const modals = this.modalService.start({ i18n, theme, targetDomElement: modalElement });
const modals = this.modalService.start({
analytics,
i18n,
theme,
targetDomElement: modalElement,
});
return {
banners,

View file

@ -25,6 +25,8 @@
"@kbn/core-theme-browser-mocks",
"@kbn/i18n",
"@kbn/react-kibana-context-render",
"@kbn/core-analytics-browser-mocks",
"@kbn/core-analytics-browser",
],
"exclude": [
"target/**/*",

View file

@ -10,6 +10,7 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import { BehaviorSubject } from 'rxjs';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { applicationServiceMock } from '@kbn/core-application-browser-mocks';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
import { overlayServiceMock } from '@kbn/core-overlays-browser-mocks';
@ -18,6 +19,7 @@ import { i18nServiceMock } from '@kbn/core-i18n-browser-mocks';
import { RenderingService } from './rendering_service';
describe('RenderingService#start', () => {
let analytics: ReturnType<typeof analyticsServiceMock.createAnalyticsServiceStart>;
let application: ReturnType<typeof applicationServiceMock.createInternalStartContract>;
let chrome: ReturnType<typeof chromeServiceMock.createStartContract>;
let overlays: ReturnType<typeof overlayServiceMock.createStartContract>;
@ -27,6 +29,8 @@ describe('RenderingService#start', () => {
let rendering: RenderingService;
beforeEach(() => {
analytics = analyticsServiceMock.createAnalyticsServiceStart();
application = applicationServiceMock.createInternalStartContract();
application.getComponent.mockReturnValue(<div>Hello application!</div>);
@ -47,6 +51,7 @@ describe('RenderingService#start', () => {
const startService = () => {
return rendering.start({
analytics,
application,
chrome,
overlays,

View file

@ -10,15 +10,17 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { pairwise, startWith } from 'rxjs/operators';
import type { ThemeServiceStart } from '@kbn/core-theme-browser';
import type { I18nStart } from '@kbn/core-i18n-browser';
import { KibanaRootContextProvider } from '@kbn/react-kibana-context-root';
import type { OverlayStart } from '@kbn/core-overlays-browser';
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import type { InternalApplicationStart } from '@kbn/core-application-browser-internal';
import type { InternalChromeStart } from '@kbn/core-chrome-browser-internal';
import type { I18nStart } from '@kbn/core-i18n-browser';
import type { OverlayStart } from '@kbn/core-overlays-browser';
import type { ThemeServiceStart } from '@kbn/core-theme-browser';
import { KibanaRootContextProvider } from '@kbn/react-kibana-context-root';
import { AppWrapper } from './app_containers';
export interface StartDeps {
analytics: AnalyticsServiceStart;
application: InternalApplicationStart;
chrome: InternalChromeStart;
overlays: OverlayStart;
@ -36,7 +38,7 @@ export interface StartDeps {
* @internal
*/
export class RenderingService {
start({ application, chrome, overlays, theme, i18n, targetDomElement }: StartDeps) {
start({ analytics, application, chrome, overlays, theme, i18n, targetDomElement }: StartDeps) {
const chromeHeader = chrome.getHeaderComponent();
const appComponent = application.getComponent();
const bannerComponent = overlays.banners.getComponent();
@ -51,7 +53,12 @@ export class RenderingService {
});
ReactDOM.render(
<KibanaRootContextProvider i18n={i18n} theme={theme} globalStyles={true}>
<KibanaRootContextProvider
analytics={analytics}
i18n={i18n}
theme={theme}
globalStyles={true}
>
<>
{/* Fixed headers */}
{chromeHeader}

View file

@ -25,6 +25,8 @@
"@kbn/core-theme-browser-mocks",
"@kbn/core-i18n-browser-mocks",
"@kbn/react-kibana-context-root",
"@kbn/core-analytics-browser",
"@kbn/core-analytics-browser-mocks",
],
"exclude": [
"target/**/*",

View file

@ -478,6 +478,7 @@ describe('#start()', () => {
await startCore();
expect(MockRenderingService.start).toHaveBeenCalledTimes(1);
expect(MockRenderingService.start).toHaveBeenCalledWith({
analytics: expect.any(Object),
application: expect.any(Object),
chrome: expect.any(Object),
overlays: expect.any(Object),

View file

@ -216,18 +216,18 @@ export class CoreSystem {
// Setup FatalErrorsService and it's dependencies first so that we're
// able to render any errors.
const injectedMetadata = this.injectedMetadata.setup();
const analytics = this.analytics.setup({ injectedMetadata });
const theme = this.theme.setup({ injectedMetadata });
this.fatalErrorsSetup = this.fatalErrors.setup({
injectedMetadata,
analytics,
theme,
i18n: this.i18n.getContext(),
});
await this.integrations.setup();
this.docLinks.setup();
const analytics = this.analytics.setup({ injectedMetadata });
this.registerLoadedKibanaEventType(analytics);
const executionContext = this.executionContext.setup({ analytics });
@ -300,11 +300,12 @@ export class CoreSystem {
const overlays = this.overlay.start({
i18n,
analytics,
theme,
uiSettings,
targetDomElement: overlayTargetDomElement,
});
const notifications = await this.notifications.start({
const notifications = this.notifications.start({
analytics,
i18n,
overlays,
@ -312,7 +313,13 @@ export class CoreSystem {
targetDomElement: notificationsTargetDomElement,
});
const customBranding = this.customBranding.start();
const application = await this.application.start({ http, theme, overlays, customBranding });
const application = await this.application.start({
http,
theme,
overlays,
customBranding,
analytics,
});
const executionContext = this.executionContext.start({
curApp$: application.currentAppId$,
@ -362,6 +369,7 @@ export class CoreSystem {
this.rendering.start({
application,
chrome,
analytics,
i18n,
overlays,
theme,

View file

@ -11,6 +11,7 @@ import { I18nProvider } from '@kbn/i18n-react';
import { KibanaRootContextProvider } from '@kbn/react-kibana-context-root';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { I18nStart } from '@kbn/core-i18n-browser';
import { createFormServicesMock } from '@kbn/management-settings-components-form/mocks';
@ -19,11 +20,13 @@ import { getSettingsMock } from '@kbn/management-settings-utilities/mocks/settin
import { SettingsApplicationProvider, SettingsApplicationServices } from '../services';
const createRootMock = () => {
const analytics = analyticsServiceMock.createAnalyticsServiceStart();
const i18n: I18nStart = {
Context: ({ children }) => <I18nProvider>{children}</I18nProvider>,
};
const theme = themeServiceMock.createStartContract();
return {
analytics,
i18n,
theme,
};

View file

@ -30,5 +30,6 @@
"@kbn/react-kibana-context-root",
"@kbn/core-theme-browser-mocks",
"@kbn/core-i18n-browser",
"@kbn/core-analytics-browser-mocks",
]
}

View file

@ -11,17 +11,20 @@ import { I18nProvider } from '@kbn/i18n-react';
import { KibanaRootContextProvider } from '@kbn/react-kibana-context-root';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { I18nStart } from '@kbn/core-i18n-browser';
import { FieldInputProvider } from '../services';
import { FieldInputServices } from '../types';
const createRootMock = () => {
const analytics = analyticsServiceMock.createAnalyticsServiceStart();
const i18n: I18nStart = {
Context: ({ children }) => <I18nProvider>{children}</I18nProvider>,
};
const theme = themeServiceMock.createStartContract();
return {
analytics,
i18n,
theme,
};

View file

@ -28,5 +28,6 @@
"@kbn/react-kibana-context-root",
"@kbn/core-theme-browser-mocks",
"@kbn/core-i18n-browser",
"@kbn/core-analytics-browser-mocks",
]
}

View file

@ -11,6 +11,7 @@ import { I18nProvider } from '@kbn/i18n-react';
import { KibanaRootContextProvider } from '@kbn/react-kibana-context-root';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { I18nStart } from '@kbn/core-i18n-browser';
import { createFieldInputServicesMock } from '@kbn/management-settings-components-field-input/mocks';
@ -19,11 +20,13 @@ import { FieldRowProvider } from '../services';
import { FieldRowServices } from '../types';
const createRootMock = () => {
const analytics = analyticsServiceMock.createAnalyticsServiceStart();
const i18n: I18nStart = {
Context: ({ children }) => <I18nProvider>{children}</I18nProvider>,
};
const theme = themeServiceMock.createStartContract();
return {
analytics,
i18n,
theme,
};

View file

@ -29,5 +29,6 @@
"@kbn/react-kibana-context-root",
"@kbn/core-theme-browser-mocks",
"@kbn/core-i18n-browser",
"@kbn/core-analytics-browser-mocks",
]
}

View file

@ -11,6 +11,7 @@ import { I18nProvider } from '@kbn/i18n-react';
import { KibanaRootContextProvider } from '@kbn/react-kibana-context-root';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { I18nStart } from '@kbn/core-i18n-browser';
import { createFieldRowServicesMock } from '@kbn/management-settings-components-field-row/mocks';
@ -18,11 +19,13 @@ import { FormProvider } from '../services';
import type { FormServices } from '../types';
const createRootMock = () => {
const analytics = analyticsServiceMock.createAnalyticsServiceStart();
const i18n: I18nStart = {
Context: ({ children }) => <I18nProvider>{children}</I18nProvider>,
};
const theme = themeServiceMock.createStartContract();
return {
analytics,
i18n,
theme,
};

View file

@ -32,5 +32,6 @@
"@kbn/management-settings-components-field-input",
"@kbn/management-settings-components-field-category",
"@kbn/management-settings-utilities",
"@kbn/core-analytics-browser-mocks",
]
}

View file

@ -7,5 +7,5 @@
*/
export { Fallback } from './src/fallback';
export { withSuspense } from './src/with_suspense';
export { getClosestLink, hasActiveModifierKey } from './src/utils';
export { withSuspense, type WithSuspenseExtendedDeps } from './src/with_suspense';

View file

@ -6,13 +6,25 @@
* Side Public License, v 1.
*/
import React, { Suspense, ComponentType, ReactElement, Ref } from 'react';
import { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import { KibanaErrorBoundary, KibanaErrorBoundaryProvider } from '@kbn/shared-ux-error-boundary';
import React, { ComponentType, ReactElement, Ref, Suspense } from 'react';
import { Fallback } from './fallback';
/**
* A HOC which supplies React.Suspense with a fallback component, and a `EuiErrorBoundary` to contain errors.
* Optional services that the Suspense wrapper can use
* @public
*/
export interface WithSuspenseExtendedDeps {
/**
* The `AnalyticsServiceStart` object from `CoreStart`
*/
analytics?: AnalyticsServiceStart;
}
/**
* A HOC which supplies React.Suspense with a fallback component, and a `KibanaErrorBoundary` to contain errors.
* @param Component A component deferred by `React.lazy`
* @param fallback A fallback component to render while things load; default is `Fallback` from SharedUX.
*/
@ -20,8 +32,8 @@ export const withSuspense = <P extends {}, R = {}>(
Component: ComponentType<P>,
fallback: ReactElement | null = <Fallback />
) =>
React.forwardRef((props: P, ref: Ref<R>) => (
<KibanaErrorBoundaryProvider>
React.forwardRef((props: P & WithSuspenseExtendedDeps, ref: Ref<R>) => (
<KibanaErrorBoundaryProvider analytics={props.analytics}>
<KibanaErrorBoundary>
<Suspense fallback={fallback}>
<Component {...props} ref={ref} />

View file

@ -17,5 +17,6 @@
],
"kbn_references": [
"@kbn/shared-ux-error-boundary",
"@kbn/core-analytics-browser",
]
}

View file

@ -6,7 +6,9 @@
* Side Public License, v 1.
*/
import { Subject } from 'rxjs';
import React, { useEffect } from 'react';
import { action } from '@storybook/addon-actions';
import type { DecoratorFn } from '@storybook/react';
import { I18nProvider } from '@kbn/i18n-react';
@ -14,6 +16,7 @@ import 'core_styles';
import { BehaviorSubject } from 'rxjs';
import { CoreTheme } from '@kbn/core-theme-browser';
import { I18nStart } from '@kbn/core-i18n-browser';
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import { KibanaRootContextProvider } from '@kbn/react-kibana-context-root';
const theme$ = new BehaviorSubject<CoreTheme>({ darkMode: false });
@ -22,6 +25,12 @@ const i18n: I18nStart = {
Context: ({ children }) => <I18nProvider>{children}</I18nProvider>,
};
const analytics: AnalyticsServiceStart = {
reportEvent: action('Report telemetry event'),
optIn: action('Opt in'),
telemetryCounter$: new Subject(),
};
/**
* Storybook decorator using the `KibanaContextProvider`. Uses the value from
* `globals` provided by the Storybook theme switcher to set the `colorMode`.
@ -34,7 +43,7 @@ const KibanaContextDecorator: DecoratorFn = (storyFn, { globals }) => {
}, [colorMode]);
return (
<KibanaRootContextProvider {...{ theme: { theme$ }, i18n }}>
<KibanaRootContextProvider {...{ theme: { theme$ }, analytics, i18n }}>
{storyFn()}
</KibanaRootContextProvider>
);

View file

@ -20,6 +20,7 @@
"@kbn/i18n-react",
"@kbn/core-i18n-browser",
"@kbn/react-kibana-context-root",
"@kbn/core-analytics-browser",
],
"exclude": [
"target/**/*",

View file

@ -27,7 +27,7 @@ export const KibanaRenderContextProvider: FC<KibanaRenderContextProviderProps> =
}) => {
return (
<KibanaRootContextProvider globalStyles={false} {...props}>
<KibanaErrorBoundaryProvider>
<KibanaErrorBoundaryProvider analytics={props.analytics}>
<KibanaErrorBoundary>{children}</KibanaErrorBoundary>
</KibanaErrorBoundaryProvider>
</KibanaRootContextProvider>

View file

@ -19,6 +19,7 @@ import { BehaviorSubject } from 'rxjs';
import { I18nProvider } from '@kbn/i18n-react';
import { KibanaRootContextProvider } from '@kbn/react-kibana-context-root';
import { action } from '@storybook/addon-actions';
import type { DecoratorFn } from '@storybook/react';
import type { CoreTheme } from '@kbn/core-theme-browser';
@ -26,6 +27,10 @@ import type { I18nStart } from '@kbn/core-i18n-browser';
const theme$ = new BehaviorSubject<CoreTheme>({ darkMode: false });
const analytics = {
reportEvent: action('telemetry-report-event'),
};
const i18n: I18nStart = {
Context: ({ children }) => <I18nProvider>{children}</I18nProvider>,
};
@ -38,7 +43,7 @@ export const KibanaContextDecorator: DecoratorFn = (storyFn, { globals }) => {
}, [colorMode]);
return (
<KibanaRootContextProvider {...{ theme: { theme$ }, i18n }}>
<KibanaRootContextProvider {...{ theme: { theme$ }, analytics, i18n }}>
{storyFn()}
</KibanaRootContextProvider>
);

View file

@ -14,6 +14,8 @@ import { useEuiTheme } from '@elastic/eui';
import type { UseEuiTheme } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import type { KibanaTheme } from '@kbn/react-kibana-context-common';
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { i18nServiceMock } from '@kbn/core-i18n-browser-mocks';
import { KibanaRootContextProvider } from './root_provider';
import { I18nStart } from '@kbn/core-i18n-browser';
@ -21,9 +23,11 @@ import { I18nStart } from '@kbn/core-i18n-browser';
describe('KibanaRootContextProvider', () => {
let euiTheme: UseEuiTheme | undefined;
let i18nMock: I18nStart;
let analytics: AnalyticsServiceStart;
beforeEach(() => {
euiTheme = undefined;
analytics = analyticsServiceMock.createAnalyticsServiceStart();
i18nMock = i18nServiceMock.createStartContract();
});
@ -56,7 +60,11 @@ describe('KibanaRootContextProvider', () => {
const coreTheme: KibanaTheme = { darkMode: true };
const wrapper = mountWithIntl(
<KibanaRootContextProvider i18n={i18nMock} theme={{ theme$: of(coreTheme) }}>
<KibanaRootContextProvider
analytics={analytics}
i18n={i18nMock}
theme={{ theme$: of(coreTheme) }}
>
<InnerComponent />
</KibanaRootContextProvider>
);
@ -70,7 +78,11 @@ describe('KibanaRootContextProvider', () => {
const coreTheme$ = new BehaviorSubject<KibanaTheme>({ darkMode: true });
const wrapper = mountWithIntl(
<KibanaRootContextProvider i18n={i18nMock} theme={{ theme$: coreTheme$ }}>
<KibanaRootContextProvider
analytics={analytics}
i18n={i18nMock}
theme={{ theme$: coreTheme$ }}
>
<InnerComponent />
</KibanaRootContextProvider>
);

View file

@ -6,7 +6,8 @@
* Side Public License, v 1.
*/
import { I18nStart } from '@kbn/core-i18n-browser';
import type { I18nStart } from '@kbn/core-i18n-browser';
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import React, { FC } from 'react';
import { KibanaEuiProvider, type KibanaEuiProviderProps } from './eui_provider';
@ -15,6 +16,8 @@ import { KibanaEuiProvider, type KibanaEuiProviderProps } from './eui_provider';
export interface KibanaRootContextProviderProps extends KibanaEuiProviderProps {
/** The `I18nStart` API from `CoreStart`. */
i18n: I18nStart;
/** The `AnalyticsServiceStart` API from `CoreStart`. */
analytics?: AnalyticsServiceStart;
}
/**

View file

@ -21,5 +21,7 @@
"@kbn/core-i18n-browser-mocks",
"@kbn/core-i18n-browser",
"@kbn/core-base-common",
"@kbn/core-analytics-browser",
"@kbn/core-analytics-browser-mocks",
]
}

View file

@ -13,11 +13,13 @@ import { useEuiTheme } from '@elastic/eui';
import type { UseEuiTheme } from '@elastic/eui';
import type { CoreTheme } from '@kbn/core/public';
import { toMountPoint } from './to_mount_point';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { i18nServiceMock } from '@kbn/core-i18n-browser-mocks';
describe('toMountPoint', () => {
let euiTheme: UseEuiTheme;
const i18n = i18nServiceMock.createStartContract();
const analytics = analyticsServiceMock.createAnalyticsServiceStart();
const InnerComponent: FC = () => {
const theme = useEuiTheme();
@ -39,7 +41,7 @@ describe('toMountPoint', () => {
it('exposes the euiTheme when `theme$` is provided', async () => {
const theme = { theme$: of<CoreTheme>({ darkMode: true }) };
const mount = toMountPoint(<InnerComponent />, { theme, i18n });
const mount = toMountPoint(<InnerComponent />, { theme, i18n, analytics });
const targetEl = document.createElement('div');
mount(targetEl);
@ -52,7 +54,7 @@ describe('toMountPoint', () => {
it('propagates changes of the theme$ observable', async () => {
const theme$ = new BehaviorSubject<CoreTheme>({ darkMode: true });
const mount = toMountPoint(<InnerComponent />, { theme: { theme$ }, i18n });
const mount = toMountPoint(<InnerComponent />, { theme: { theme$ }, i18n, analytics });
const targetEl = document.createElement('div');
mount(targetEl);

View file

@ -14,7 +14,10 @@ import {
KibanaRenderContextProviderProps,
} from '@kbn/react-kibana-context-render';
export type ToMountPointParams = Pick<KibanaRenderContextProviderProps, 'i18n' | 'theme'>;
export type ToMountPointParams = Pick<
KibanaRenderContextProviderProps,
'analytics' | 'i18n' | 'theme'
>;
/**
* MountPoint converter for react nodes.

View file

@ -20,5 +20,6 @@
"@kbn/i18n",
"@kbn/core-i18n-browser-mocks",
"@kbn/react-kibana-context-render",
"@kbn/core-analytics-browser-mocks",
]
}

View file

@ -6,6 +6,9 @@
* Side Public License, v 1.
*/
export { KibanaErrorBoundary } from './src/ui/error_boundary';
export { KibanaErrorBoundaryProvider } from './src/services/error_boundary_services';
export { KibanaErrorBoundary } from './src/ui/error_boundary';
export { ThrowIfError } from './src/ui/throw_if_error';
export { REACT_FATAL_ERROR_EVENT_TYPE, reactFatalErrorSchema } from './lib/telemetry_events';
export type { ReactFatalError } from './lib/telemetry_events';

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
/** @internal */
export const REACT_FATAL_ERROR_EVENT_TYPE = 'fatal-error-react';
/** @internal */
export interface ReactFatalError {
component_name: string;
error_message: string;
}
/** @internal */
export const reactFatalErrorSchema = {
component_name: {
type: 'keyword' as const,
_meta: {
description: 'Name of react component that threw an error',
optional: false as const,
},
},
error_message: {
type: 'keyword' as const,
_meta: {
description: 'Message from the error',
optional: false as const,
},
},
};

View file

@ -10,8 +10,11 @@ import { KibanaErrorService } from '../../src/services/error_service';
import { KibanaErrorBoundaryServices } from '../../types';
export const getServicesMock = (): KibanaErrorBoundaryServices => {
const mockDeps = {
analytics: { reportEvent: jest.fn() },
};
return {
onClickRefresh: jest.fn().mockResolvedValue(undefined),
errorService: new KibanaErrorService(),
errorService: new KibanaErrorService(mockDeps),
};
};

View file

@ -25,15 +25,13 @@ export class KibanaErrorBoundaryStorybookMock extends AbstractStorybookMock<
dependencies = [];
getServices(params: Params = {}): KibanaErrorBoundaryServices {
const reloadWindowAction = action('Reload window');
const onClickRefresh = () => {
reloadWindowAction();
};
const onClickRefresh = action('Reload window');
const analytics = { reportEvent: action('Report telemetry event') };
return {
...params,
onClickRefresh,
errorService: new KibanaErrorService(),
errorService: new KibanaErrorService({ analytics }),
};
}

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { render } from '@testing-library/react';
import React from 'react';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { KibanaErrorBoundaryProviderDeps } from '../../types';
import { KibanaErrorBoundary, KibanaErrorBoundaryProvider } from '../..';
import { BadComponent } from '../../mocks';
describe('<KibanaErrorBoundaryProvider>', () => {
let analytics: KibanaErrorBoundaryProviderDeps['analytics'];
beforeEach(() => {
analytics = analyticsServiceMock.createAnalyticsServiceStart();
});
it('creates a context of services for KibanaErrorBoundary', async () => {
const reportEventSpy = jest.spyOn(analytics!, 'reportEvent');
const { findByTestId } = render(
<KibanaErrorBoundaryProvider analytics={analytics}>
<KibanaErrorBoundary>
<BadComponent />
</KibanaErrorBoundary>
</KibanaErrorBoundaryProvider>
);
(await findByTestId('clickForErrorBtn')).click();
expect(reportEventSpy).toBeCalledWith('fatal-error-react', {
component_name: 'BadComponent',
error_message: 'Error: This is an error to show the test user!',
});
});
it('uses higher-level context if available', async () => {
const reportEventSpy1 = jest.spyOn(analytics!, 'reportEvent');
const analytics2 = analyticsServiceMock.createAnalyticsServiceStart();
const reportEventSpy2 = jest.spyOn(analytics2, 'reportEvent');
const { findByTestId } = render(
<KibanaErrorBoundaryProvider analytics={analytics}>
<KibanaErrorBoundary>
Hello world
<KibanaErrorBoundaryProvider analytics={analytics2}>
<KibanaErrorBoundary>
<BadComponent />
</KibanaErrorBoundary>
</KibanaErrorBoundaryProvider>
</KibanaErrorBoundary>
</KibanaErrorBoundaryProvider>
);
(await findByTestId('clickForErrorBtn')).click();
expect(reportEventSpy2).not.toBeCalled();
expect(reportEventSpy1).toBeCalledWith('fatal-error-react', {
component_name: 'BadComponent',
error_message: 'Error: This is an error to show the test user!',
});
});
});

View file

@ -8,39 +8,49 @@
import React, { FC, useContext, useMemo } from 'react';
import { KibanaErrorBoundaryServices } from '../../types';
import { KibanaErrorBoundaryProviderDeps, KibanaErrorBoundaryServices } from '../../types';
import { KibanaErrorService } from './error_service';
const Context = React.createContext<KibanaErrorBoundaryServices | null>(null);
/**
* A Context Provider for Jest and Storybooks
* @internal
*/
export const KibanaErrorBoundaryDepsProvider: FC<KibanaErrorBoundaryServices> = ({
children,
onClickRefresh,
errorService,
}) => {
return <Context.Provider value={{ onClickRefresh, errorService }}>{children}</Context.Provider>;
};
}) => <Context.Provider value={{ onClickRefresh, errorService }}>{children}</Context.Provider>;
/**
* Kibana-specific Provider that maps dependencies to services.
* Provider that uses dependencies to give context to the KibanaErrorBoundary component
* This provider is aware if services were already created from a higher level of the component tree
* @public
*/
export const KibanaErrorBoundaryProvider: FC = ({ children }) => {
const value: KibanaErrorBoundaryServices = useMemo(
() => ({
export const KibanaErrorBoundaryProvider: FC<KibanaErrorBoundaryProviderDeps> = ({
children,
analytics,
}) => {
const parentContext = useContext(Context);
const value: KibanaErrorBoundaryServices = useMemo(() => {
// FIXME: analytics dep is optional - know when not to overwrite
if (parentContext) {
return parentContext;
}
return {
onClickRefresh: () => window.location.reload(),
errorService: new KibanaErrorService(),
}),
[]
);
errorService: new KibanaErrorService({ analytics }),
};
}, [parentContext, analytics]);
return <Context.Provider value={value}>{children}</Context.Provider>;
};
/**
* React hook for accessing pre-wired services.
* Utility that provides context
* @internal
*/
export function useErrorBoundary(): KibanaErrorBoundaryServices {
const context = useContext(Context);

View file

@ -8,8 +8,11 @@
import { KibanaErrorService } from './error_service';
describe('KibanaErrorBoundary KibanaErrorService', () => {
const service = new KibanaErrorService();
describe('KibanaErrorBoundary Error Service', () => {
const mockDeps = {
analytics: { reportEvent: jest.fn() },
};
const service = new KibanaErrorService(mockDeps);
it('construction', () => {
expect(service).toHaveProperty('registerError');
@ -71,4 +74,23 @@ describe('KibanaErrorBoundary KibanaErrorService', () => {
// should not be "ThrowIfError"
expect(serviceError.name).toBe('BadComponent');
});
it('captures the error event for telemetry', () => {
jest.resetAllMocks();
const testFatal = new Error('This is an outrageous and fatal error');
const errorInfo = {
componentStack: `
at OutrageousMaker (http://localhost:9001/main.iframe.bundle.js:11616:73)
`,
};
service.registerError(testFatal, errorInfo);
expect(mockDeps.analytics.reportEvent).toHaveBeenCalledTimes(1);
expect(mockDeps.analytics.reportEvent).toHaveBeenCalledWith('fatal-error-react', {
component_name: 'OutrageousMaker',
error_message: 'Error: This is an outrageous and fatal error',
});
});
});

View file

@ -7,7 +7,9 @@
*/
import React from 'react';
import { ThrowIfError } from '../..';
import { REACT_FATAL_ERROR_EVENT_TYPE } from '../../lib/telemetry_events';
import { KibanaErrorBoundaryProviderDeps } from '../../types';
import { ThrowIfError } from '../ui/throw_if_error';
const MATCH_CHUNK_LOADERROR = /ChunkLoadError/;
@ -18,12 +20,22 @@ interface ErrorServiceError {
isFatal: boolean;
}
interface Deps {
analytics?: KibanaErrorBoundaryProviderDeps['analytics'];
}
/**
* Kibana Error Boundary Services: Error Service
* Each Error Boundary tracks an instance of this class
* @internal
*/
export class KibanaErrorService {
private analytics?: Deps['analytics'];
constructor(deps: Deps) {
this.analytics = deps.analytics;
}
/**
* Determines if the error fallback UI should appear as an apologetic but promising "Refresh" button,
* or treated with "danger" coloring and include a detailed error message.
@ -62,7 +74,6 @@ export class KibanaErrorService {
/**
* Creates a decorated error object
* TODO: capture telemetry
*/
public registerError(
error: Error,
@ -71,6 +82,18 @@ export class KibanaErrorService {
const isFatal = this.getIsFatal(error);
const name = this.getErrorComponentName(errorInfo);
try {
if (isFatal) {
this.analytics?.reportEvent(REACT_FATAL_ERROR_EVENT_TYPE, {
component_name: name,
error_message: error.toString(),
});
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
return {
error,
errorInfo,

View file

@ -12,8 +12,9 @@ import React, { FC } from 'react';
import { KibanaErrorBoundary } from '../..';
import { BadComponent, ChunkLoadErrorComponent, getServicesMock } from '../../mocks';
import { KibanaErrorBoundaryServices } from '../../types';
import { errorMessageStrings as strings } from './message_strings';
import { KibanaErrorBoundaryDepsProvider } from '../services/error_boundary_services';
import { KibanaErrorService } from '../services/error_service';
import { errorMessageStrings as strings } from './message_strings';
describe('<KibanaErrorBoundary>', () => {
let services: KibanaErrorBoundaryServices;
@ -72,4 +73,23 @@ describe('<KibanaErrorBoundary>', () => {
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
it('captures the error event for telemetry', async () => {
const mockDeps = {
analytics: { reportEvent: jest.fn() },
};
services.errorService = new KibanaErrorService(mockDeps);
const { findByTestId } = render(
<Template>
<BadComponent />
</Template>
);
(await findByTestId('clickForErrorBtn')).click();
expect(mockDeps.analytics.reportEvent).toHaveBeenCalledWith('fatal-error-react', {
component_name: 'BadComponent',
error_message: 'Error: This is an error to show the test user!',
});
});
});

View file

@ -21,5 +21,6 @@
"kbn_references": [
"@kbn/shared-ux-storybook-mock",
"@kbn/i18n",
"@kbn/core-analytics-browser-mocks",
]
}

View file

@ -16,3 +16,15 @@ export interface KibanaErrorBoundaryServices {
onClickRefresh: () => void;
errorService: KibanaErrorService;
}
/**
* {analytics: AnalyticsServiceStart | undefined}
* @public
*/
export interface KibanaErrorBoundaryProviderDeps {
analytics:
| {
reportEvent: (eventType: string, eventData: object) => void;
}
| undefined;
}

View file

@ -11,6 +11,7 @@ import { Observable } from 'rxjs';
import { I18nProvider } from '@kbn/i18n-react';
import type { MountPoint } from '@kbn/core/public';
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import type { I18nStart } from '@kbn/core-i18n-browser';
import type { CoreTheme, ThemeServiceStart } from '@kbn/core-theme-browser';
import { defaultTheme } from '@kbn/react-kibana-context-common';
@ -35,6 +36,7 @@ const i18n: I18nStart = {
* @deprecated use `ToMountPointParams` from `@kbn/react-kibana-mount`
*/
export interface ToMountPointOptions {
analytics?: AnalyticsServiceStart;
theme$?: Observable<CoreTheme>;
}
@ -43,8 +45,8 @@ export interface ToMountPointOptions {
*/
export const toMountPoint = (
node: React.ReactNode,
{ theme$ }: ToMountPointOptions = {}
{ analytics, theme$ }: ToMountPointOptions = {}
): MountPoint => {
const theme = theme$ ? { theme$ } : themeStart;
return _toMountPoint(node, { theme, i18n });
return _toMountPoint(node, { analytics, theme, i18n });
};

View file

@ -24,6 +24,7 @@
"@kbn/react-kibana-context-common",
"@kbn/react-kibana-context-styled",
"@kbn/code-editor",
"@kbn/core-analytics-browser",
],
"exclude": [
"target/**/*",

View file

@ -132,6 +132,7 @@ export const ManagementApp = ({
setBreadcrumbs={setBreadcrumbsScoped}
onAppMounted={onAppMounted}
sections={sections}
analytics={dependencies.coreStart.analytics}
/>
</KibanaPageTemplate>
</KibanaThemeProvider>

View file

@ -9,7 +9,12 @@
import React, { memo } from 'react';
import { Redirect } from 'react-router-dom';
import { Router, Routes, Route } from '@kbn/shared-ux-router';
import { AppMountParameters, ChromeBreadcrumb, ScopedHistory } from '@kbn/core/public';
import {
AnalyticsServiceStart,
AppMountParameters,
ChromeBreadcrumb,
ScopedHistory,
} from '@kbn/core/public';
import { KibanaErrorBoundary, KibanaErrorBoundaryProvider } from '@kbn/shared-ux-error-boundary';
import { ManagementAppWrapper } from '../management_app_wrapper';
import { ManagementLandingPage } from '../landing';
@ -21,12 +26,20 @@ interface ManagementRouterProps {
setBreadcrumbs: (crumbs?: ChromeBreadcrumb[], appHistory?: ScopedHistory) => void;
onAppMounted: (id: string) => void;
sections: ManagementSection[];
analytics: AnalyticsServiceStart;
}
export const ManagementRouter = memo(
({ history, setBreadcrumbs, onAppMounted, sections, theme$ }: ManagementRouterProps) => {
({
history,
setBreadcrumbs,
onAppMounted,
sections,
theme$,
analytics,
}: ManagementRouterProps) => {
return (
<KibanaErrorBoundaryProvider>
<KibanaErrorBoundaryProvider analytics={analytics}>
<KibanaErrorBoundary>
<Router history={history}>
<Routes>

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
import {
REACT_FATAL_ERROR_EVENT_TYPE,
ReactFatalError,
reactFatalErrorSchema,
} from '@kbn/shared-ux-error-boundary';
interface SetupDeps {
analytics: AnalyticsServiceSetup;
}
export const registrations = {
setup(deps: SetupDeps) {
// register event type for errors caught and reported by @kbn/shared-ux-error-boundary
deps.analytics.registerEventType<ReactFatalError>({
eventType: REACT_FATAL_ERROR_EVENT_TYPE,
schema: reactFatalErrorSchema,
});
},
};

View file

@ -22,6 +22,7 @@ import type { BrowserShortUrlClient } from './url_service/short_urls/short_url_c
import { AnonymousAccessServiceContract } from '../common';
import { LegacyShortUrlLocatorDefinition } from '../common/url_service/locators/legacy_short_url_locator';
import { ShortUrlRedirectLocatorDefinition } from '../common/url_service/locators/short_url_redirect_locator';
import { registrations } from './lib/registrations';
import type { BrowserUrlService } from './types';
/** @public */
@ -68,7 +69,7 @@ export class SharePlugin implements Plugin<SharePluginSetup, SharePluginStart> {
constructor(private readonly initializerContext: PluginInitializerContext) {}
public setup(core: CoreSetup): SharePluginSetup {
const { http } = core;
const { analytics, http } = core;
const { basePath } = http;
this.url = new UrlService<BrowserShortUrlClientFactoryCreateParams, BrowserShortUrlClient>({
@ -106,6 +107,8 @@ export class SharePlugin implements Plugin<SharePluginSetup, SharePluginStart> {
this.redirectManager.registerLocatorRedirectApp(core);
this.redirectManager.registerLegacyShortUrlRedirectApp(core);
registrations.setup({ analytics });
return {
...this.shareMenuRegistry.setup(),
url: this.url,

View file

@ -15,6 +15,8 @@
"@kbn/core-custom-branding-browser",
"@kbn/core-saved-objects-utils-server",
"@kbn/react-kibana-context-theme",
"@kbn/core-analytics-browser",
"@kbn/shared-ux-error-boundary",
],
"exclude": [
"target/**/*",

View file

@ -209,7 +209,7 @@ export function ObservabilityPageTemplate({
: undefined
}
>
<KibanaErrorBoundaryProvider>
<KibanaErrorBoundaryProvider analytics={services.analytics}>
<KibanaErrorBoundary>
<KibanaPageTemplate.Section
component="div"

View file

@ -51,6 +51,7 @@ const StartAppComponent: FC<StartAppComponent> = ({
const services = useKibana().services;
const {
i18n,
analytics,
application: { capabilities },
uiActions,
upselling,
@ -59,7 +60,7 @@ const StartAppComponent: FC<StartAppComponent> = ({
const [darkMode] = useUiSetting$<boolean>(DEFAULT_DARK_MODE);
return (
<KibanaErrorBoundaryProvider>
<KibanaErrorBoundaryProvider analytics={analytics}>
<KibanaErrorBoundary>
<i18n.Context>
<ManageGlobalToaster>