Add telemetry for toasts (#166466)

## Summary

Address #166459

This PR scaffolds telemetry into the notification service, to enable
reporting when deduped toasts are dismissed. When a deduped toast is
dismissed; the message recurrence count and the toast message would be
emitted.

Telemetry Event Definition;

```typescript
{
 eventType: "global_toast_list_toast_dismissed",
 schema: {
   "toast_deduplication_count": {
      type: "long",
      _meta: {
        description: "toast message text"
      }
   },
  "toast_message": {
      type: "keyword",
      _meta: {
        description: "recurrence count for particular toast message"
      }
   },
 "toast_message_type": {
      type: "keyword",
      _meta: {
        description: "toast message type, accepted values are warning, danger, primary"
      }
   }
 }
}
```

Testing;
- Ensure you have an existing dashboard, if you don't installing any
sample data would bootstrap a dashboard automatically
- Search for `visualize library` from the global search bar, and
navigate to visualize library.
- Attempt to create a visualization, select the `TSVB` visualization
type. At this step there typically wouldn't be an error
- We can simulate an error, by open Dev tools and blocking the URL
`/internal/metrics/vis/data` like so <img width="604" alt="Screenshot
2023-09-21 at 11 49 41"
src="ad320569-33b2-4335-8052-981d1761ea67">
on doing this, we then attempt refreshing the query, we would then be
presented with an error toast, similar to the screenshot below;
<img width="482" alt="Screenshot 2023-09-21 at 11 52 51"
src="fef07e72-625b-4457-abc3-9214d64f9e48">
click the refresh query button as much as you like to increase it's
recurrence count.
- From here on we verify that; clicking the dismiss button sends a
telemetry event, also not interacting with the toast and it's display
lifetime elapsing with the toast clearing itself, the telemetry gets
sent still. In either case there should be a request calling the
resource `kibana_browser` with a payload containing the telemetry event
definition above.


### Checklist

<!-- Delete any items that are not applicable to this PR. -->

- [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

<!-- - [ ] Any UI touched in this PR is usable by keyboard only (learn
more about [keyboard
accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ->


### Risk Matrix

Delete this section if it is not applicable to this PR.

Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.

When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:

| Risk | Probability | Severity | Mitigation/Notes |

|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces&mdash;unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes&mdash;Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Eyo O. Eyo 2023-09-26 16:06:37 +02:00 committed by GitHub
parent d1155f3273
commit ec20d615de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 532 additions and 253 deletions

View file

@ -9,6 +9,7 @@
import { i18n } from '@kbn/i18n';
import { Subscription } from 'rxjs';
import type { AnalyticsServiceStart, AnalyticsServiceSetup } 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';
@ -16,8 +17,10 @@ import type { OverlayStart } from '@kbn/core-overlays-browser';
import type { NotificationsSetup, NotificationsStart } from '@kbn/core-notifications-browser';
import type { PublicMethodsOf } from '@kbn/utility-types';
import { showErrorDialog, ToastsService } from './toasts';
import { EventReporter, eventTypes } from './toasts/telemetry';
export interface SetupDeps {
analytics: AnalyticsServiceSetup;
uiSettings: IUiSettingsClient;
}
@ -25,6 +28,7 @@ export interface StartDeps {
i18n: I18nStart;
overlays: OverlayStart;
theme: ThemeServiceStart;
analytics: AnalyticsServiceStart;
targetDomElement: HTMLElement;
}
@ -38,7 +42,11 @@ export class NotificationsService {
this.toasts = new ToastsService();
}
public setup({ uiSettings }: SetupDeps): NotificationsSetup {
public setup({ uiSettings, analytics }: SetupDeps): NotificationsSetup {
eventTypes.forEach((eventType) => {
analytics.registerEventType(eventType);
});
const notificationSetup = { toasts: this.toasts.setup({ uiSettings }) };
this.uiSettingsErrorSubscription = uiSettings.getUpdateErrors$().subscribe((error: Error) => {
@ -54,6 +62,7 @@ export class NotificationsService {
}
public start({
analytics,
i18n: i18nDep,
overlays,
theme,
@ -63,8 +72,11 @@ export class NotificationsService {
const toastsContainer = document.createElement('div');
targetDomElement.appendChild(toastsContainer);
const eventReporter = new EventReporter({ analytics });
return {
toasts: this.toasts.start({
eventReporter,
i18n: i18nDep,
overlays,
theme,

View file

@ -1,123 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`global_toast_list with duplicate elements renders the list with a single element 1`] = `
<EuiGlobalToastList
aria-label="Notification message list"
data-test-subj="globalToastList"
dismissToast={[Function]}
toastLifeTimeMs={Infinity}
toasts={
Array [
Object {
"id": "0",
"text": "You've got mail!",
"title": <TitleWithBadge
counter={4}
title="AOL Notifications"
/>,
"toastLifeTimeMs": 5000,
},
]
}
/>
`;
exports[`global_toast_list with duplicate elements, using MountPoints renders the all separate elements element: euiToastList 1`] = `
<EuiGlobalToastList
aria-label="Notification message list"
data-test-subj="globalToastList"
dismissToast={[Function]}
toastLifeTimeMs={Infinity}
toasts={
Array [
Object {
"id": "0",
"text": "You've got mail!",
"title": <MountWrapper
mount={[Function]}
/>,
"toastLifeTimeMs": 5000,
},
Object {
"id": "1",
"text": "You've got mail!",
"title": <MountWrapper
mount={[Function]}
/>,
"toastLifeTimeMs": 5000,
},
Object {
"id": "2",
"text": "You've got mail!",
"title": <MountWrapper
mount={[Function]}
/>,
"toastLifeTimeMs": 5000,
},
Object {
"id": "3",
"text": "You've got mail!",
"title": <MountWrapper
mount={[Function]}
/>,
"toastLifeTimeMs": 5000,
},
]
}
/>
`;
exports[`global_toast_list with duplicate elements, using MountPoints renders the all separate elements element: globalToastList 1`] = `
<EuiGlobalToastList
aria-label="Notification message list"
data-test-subj="globalToastList"
dismissToast={[Function]}
toastLifeTimeMs={Infinity}
toasts={
Array [
Object {
"id": "0",
"text": "You've got mail!",
"title": <MountWrapper
mount={[Function]}
/>,
"toastLifeTimeMs": 5000,
},
Object {
"id": "1",
"text": "You've got mail!",
"title": <MountWrapper
mount={[Function]}
/>,
"toastLifeTimeMs": 5000,
},
Object {
"id": "2",
"text": "You've got mail!",
"title": <MountWrapper
mount={[Function]}
/>,
"toastLifeTimeMs": 5000,
},
Object {
"id": "3",
"text": "You've got mail!",
"title": <MountWrapper
mount={[Function]}
/>,
"toastLifeTimeMs": 5000,
},
]
}
/>
`;
exports[`renders matching snapshot 1`] = `
<EuiGlobalToastList
aria-label="Notification message list"
data-test-subj="globalToastList"
dismissToast={[Function]}
toastLifeTimeMs={Infinity}
toasts={Array []}
/>
`;

View file

@ -19,6 +19,11 @@ Array [
>
<GlobalToastList
dismissToast={[Function]}
reportEvent={
EventReporter {
"reportEvent": [MockFunction],
}
}
toasts$={
Observable {
"source": BehaviorSubject {

View file

@ -6,22 +6,37 @@
* Side Public License, v 1.
*/
import { EuiGlobalToastList } from '@elastic/eui';
import { Toast } from '@kbn/core-notifications-browser/src/types';
import { shallow } from 'enzyme';
import React from 'react';
import { Observable, from, EMPTY } from 'rxjs';
import React, { type ComponentProps } from 'react';
import { Observable, from, EMPTY, BehaviorSubject } from 'rxjs';
import { screen, render, fireEvent, act } from '@testing-library/react';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { EuiToast } from '@elastic/eui';
import { EventReporter } from './telemetry';
import { GlobalToastList } from './global_toast_list';
const mockDismissToast = jest.fn();
const mockAnalytics = analyticsServiceMock.createAnalyticsServiceStart();
function render(props: Partial<GlobalToastList['props']> = {}) {
return <GlobalToastList dismissToast={mockDismissToast} toasts$={EMPTY} {...props} />;
const sharedProps = {
toasts$: EMPTY,
dismissToast: jest.fn(),
reportEvent: new EventReporter({ analytics: mockAnalytics }),
};
function RenderToastList(props: Partial<ComponentProps<typeof GlobalToastList>> = {}) {
return <GlobalToastList {...sharedProps} {...props} />;
}
it('renders matching snapshot', () => {
expect(shallow(render())).toMatchSnapshot();
const dummyToastText = `You've got mail!`;
const dummyToastTitle = `AOL Notifications`;
const createMockToast = (id: any, type?: ComponentProps<typeof EuiToast>['color']): Toast => ({
id: id.toString(),
text: dummyToastText,
title: dummyToastTitle,
toastLifeTimeMs: 5000,
color: type,
});
it('subscribes to toasts$ on mount and unsubscribes on unmount', () => {
@ -31,102 +46,291 @@ it('subscribes to toasts$ on mount and unsubscribes on unmount', () => {
return unsubscribeSpy;
});
const component = render({
toasts$: new Observable<any>(subscribeSpy),
});
const { unmount } = render(<RenderToastList toasts$={new Observable<any>(subscribeSpy)} />);
expect(subscribeSpy).not.toHaveBeenCalled();
const el = shallow(component);
expect(subscribeSpy).toHaveBeenCalledTimes(1);
expect(unsubscribeSpy).not.toHaveBeenCalled();
el.unmount();
unmount();
expect(subscribeSpy).toHaveBeenCalledTimes(1);
expect(unsubscribeSpy).toHaveBeenCalledTimes(1);
});
it('passes latest value from toasts$ to <EuiGlobalToastList />', () => {
const el = shallow(
render({
toasts$: from([[], [{ id: '1' }], [{ id: '1' }, { id: '2' }]]) as any,
})
);
it('uses the latest value from toasts$ passed to <EuiGlobalToastList /> to render the right number of toasts', () => {
const toastObservable$ = new BehaviorSubject([{ id: '1' }, { id: '2' }]);
expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([{ id: '1' }, { id: '2' }]);
render(<RenderToastList toasts$={toastObservable$.asObservable() as any} />);
expect(screen.getAllByLabelText('Notification')).toHaveLength(2);
act(() => {
toastObservable$.next([...toastObservable$.getValue(), { id: '3' }]);
});
expect(screen.getAllByLabelText('Notification')).toHaveLength(3);
});
describe('global_toast_list with duplicate elements', () => {
const dummyText = `You've got mail!`;
const dummyTitle = `AOL Notifications`;
const toast = (id: any): Toast => ({
id: id.toString(),
text: dummyText,
title: dummyTitle,
toastLifeTimeMs: 5000,
const TOAST_DUPLICATE_COUNT = 4;
function ToastListWithDuplicates() {
return (
<RenderToastList
toasts$={
from([
Array.from(new Array(TOAST_DUPLICATE_COUNT)).map((_, idx) => createMockToast(idx)),
]) as any
}
/>
);
}
beforeEach(() => {
jest.useFakeTimers();
});
const globalToastList = shallow(
render({
toasts$: from([[toast(0), toast(1), toast(2), toast(3)]]) as any,
})
);
const euiToastList = globalToastList.find(EuiGlobalToastList);
const toastsProp = euiToastList.prop('toasts');
it('renders the list with a single element', () => {
expect(toastsProp).toBeDefined();
expect(toastsProp).toHaveLength(1);
expect(euiToastList).toMatchSnapshot();
afterEach(() => {
jest.useRealTimers();
});
it('renders the single toast with the common text', () => {
const firstRenderedToast = toastsProp![0];
expect(firstRenderedToast.text).toBe(dummyText);
it('renders the toast list with a single toast when toasts matching deduplication heuristics are passed', () => {
render(<ToastListWithDuplicates />);
const { 0: firstToast, length: toastCount } = screen.getAllByLabelText('Notification');
expect(toastCount).toEqual(1);
expect(screen.getAllByText(dummyToastText)).toHaveLength(1);
expect(firstToast.querySelector('.euiNotificationBadge')?.innerHTML).toEqual('4');
});
it(`calls all toast's dismiss when closed`, () => {
const firstRenderedToast = toastsProp![0];
const dismissToast = globalToastList.prop('dismissToast');
dismissToast(firstRenderedToast);
it('renders a single toast also when toast titles are mount points are used that match the deduplication heuristics', () => {
const createMockToastWithMountPoints = (id: any): Toast => ({
id: id.toString(),
text: dummyToastText,
title: (element) => {
const a = document.createElement('a');
a.innerText = 'Click me!';
a.href = 'https://elastic.co';
element.appendChild(a);
return () => element.removeChild(a);
},
toastLifeTimeMs: 5000,
});
expect(mockDismissToast).toHaveBeenCalledTimes(4);
expect(mockDismissToast).toHaveBeenCalledWith('0');
expect(mockDismissToast).toHaveBeenCalledWith('1');
expect(mockDismissToast).toHaveBeenCalledWith('2');
expect(mockDismissToast).toHaveBeenCalledWith('3');
render(
<RenderToastList
toasts$={
from([
Array.from(new Array(TOAST_DUPLICATE_COUNT)).map((_, idx) =>
createMockToastWithMountPoints(idx)
),
]) as any
}
/>
);
const renderedToasts = screen.getAllByText(dummyToastText);
expect(renderedToasts).toHaveLength(TOAST_DUPLICATE_COUNT);
});
it(`when a represented toast is closed, the provided dismiss action is called for all its internal toasts`, () => {
render(<ToastListWithDuplicates />);
const { 0: toastDismissButton, length: toastDismissButtonLength } =
screen.getAllByLabelText('Dismiss toast');
expect(toastDismissButtonLength).toEqual(1);
fireEvent.click(toastDismissButton);
act(() => {
// This is so that the toast fade out animation succesfully runs,
// only after this is the dismiss method invoked
jest.runOnlyPendingTimers();
});
expect(sharedProps.dismissToast).toHaveBeenCalledTimes(TOAST_DUPLICATE_COUNT);
expect(sharedProps.dismissToast).toHaveBeenCalledWith('0');
expect(sharedProps.dismissToast).toHaveBeenCalledWith('1');
expect(sharedProps.dismissToast).toHaveBeenCalledWith('2');
expect(sharedProps.dismissToast).toHaveBeenCalledWith('3');
});
});
describe('global_toast_list with duplicate elements, using MountPoints', () => {
const dummyText = `You've got mail!`;
const toast = (id: any): Toast => ({
id: id.toString(),
text: dummyText,
title: (element) => {
const a = document.createElement('a');
a.innerText = 'Click me!';
a.href = 'https://elastic.co';
element.appendChild(a);
return () => element.removeChild(a);
},
toastLifeTimeMs: 5000,
describe('global_toast_list toast dismissal telemetry', () => {
beforeEach(() => {
jest.useFakeTimers();
});
const globalToastList = shallow(
render({
toasts$: from([[toast(0), toast(1), toast(2), toast(3)]]) as any,
})
);
afterEach(() => {
jest.useRealTimers();
jest.resetAllMocks();
});
const euiToastList = globalToastList.find(EuiGlobalToastList);
const toastsProp = euiToastList.prop('toasts');
it('does not invoke the reportEvent method when there is no recurring toast', async () => {
const onDimissReporterSpy = jest.spyOn(sharedProps.reportEvent, 'onDismissToast');
it('renders the all separate elements element', () => {
expect(toastsProp).toBeDefined();
expect(toastsProp).toHaveLength(4);
expect(euiToastList).toMatchSnapshot('euiToastList');
expect(globalToastList).toMatchSnapshot('globalToastList');
const toastObservable$ = new BehaviorSubject([createMockToast(1)]);
sharedProps.dismissToast.mockImplementation((toastId: string) =>
act(() => {
const toastList = toastObservable$.getValue();
toastObservable$.next(toastList.filter((t) => t.id !== toastId));
})
);
render(<RenderToastList toasts$={toastObservable$.asObservable() as any} />);
const { 0: toastDismissButton, length: toastDismissButtonLength } =
screen.getAllByLabelText('Dismiss toast');
expect(toastDismissButtonLength).toEqual(1);
fireEvent.click(toastDismissButton);
act(() => {
// This is so that the toast fade out animation succesfully runs,
// only after this is the dismiss method invoked
jest.runOnlyPendingTimers();
});
expect(sharedProps.dismissToast).toHaveBeenCalled();
expect(onDimissReporterSpy).not.toBeCalled();
expect(screen.queryByLabelText('Notification')).toBeNull();
});
it('does not invoke the reportEvent method for a recurring toast of the success type', () => {
const REPEATED_TOAST_COUNT = 2;
const onDimissReporterSpy = jest.spyOn(sharedProps.reportEvent, 'onDismissToast');
const toastObservable$ = new BehaviorSubject(
Array.from(new Array(2)).map((_, idx) => createMockToast(idx, 'success'))
);
sharedProps.dismissToast.mockImplementation((toastId: string) =>
act(() => {
const toastList = toastObservable$.getValue();
toastObservable$.next(toastList.filter((t) => t.id !== toastId));
})
);
render(<RenderToastList toasts$={toastObservable$.asObservable() as any} />);
const { 0: toastDismissButton, length: toastDismissButtonLength } =
screen.getAllByLabelText('Dismiss toast');
expect(toastDismissButtonLength).toEqual(1);
fireEvent.click(toastDismissButton);
act(() => {
// This is so that the toast fade out animation succesfully runs,
// only after this is the dismiss method invoked
jest.runOnlyPendingTimers();
});
expect(sharedProps.dismissToast).toHaveBeenCalledTimes(REPEATED_TOAST_COUNT);
expect(onDimissReporterSpy).not.toBeCalled();
expect(screen.queryByLabelText('Notification')).toBeNull();
});
it('invokes the reportEvent method for a recurring toast of allowed type that is not success', () => {
const REPEATED_TOAST_COUNT = 4;
const onDimissReporterSpy = jest.spyOn(sharedProps.reportEvent, 'onDismissToast');
const toastObservable$ = new BehaviorSubject(
Array.from(new Array(REPEATED_TOAST_COUNT)).map((_, idx) => createMockToast(idx, 'warning'))
);
sharedProps.dismissToast.mockImplementation((toastId: string) =>
act(() => {
const toastList = toastObservable$.getValue();
toastObservable$.next(toastList.filter((t) => t.id !== toastId));
})
);
render(<RenderToastList toasts$={toastObservable$.asObservable() as any} />);
const { 0: toastDismissButton, length: toastDismissButtonLength } =
screen.getAllByLabelText('Dismiss toast');
expect(toastDismissButtonLength).toEqual(1);
fireEvent.click(toastDismissButton);
act(() => {
// This is so that the toast fade out animation succesfully runs,
// only after this is the dismiss method invoked
jest.runOnlyPendingTimers();
});
expect(sharedProps.dismissToast).toHaveBeenCalledTimes(REPEATED_TOAST_COUNT);
expect(onDimissReporterSpy).toHaveBeenCalledWith(
expect.objectContaining({
recurrenceCount: REPEATED_TOAST_COUNT,
})
);
expect(screen.queryByLabelText('Notification')).toBeNull();
});
it('invokes the reportEvent method when the clear all button is clicked', () => {
const UNIQUE_TOASTS_COUNT = 4;
const REPEATED_COUNT_PER_UNIQUE_TOAST = 2;
const onDimissReporterSpy = jest.spyOn(sharedProps.reportEvent, 'onDismissToast');
const toastObservable$ = new BehaviorSubject<Toast[]>(
Array.from(new Array(UNIQUE_TOASTS_COUNT)).reduce((acc, _, idx) => {
return acc.concat(
Array.from(new Array(REPEATED_COUNT_PER_UNIQUE_TOAST)).map(() => ({
...createMockToast(idx, 'warning'),
title: `${dummyToastTitle}_${idx}`,
}))
);
}, [])
);
sharedProps.dismissToast.mockImplementation((toastId: string) =>
act(() => {
const toastList = toastObservable$.getValue();
toastObservable$.next(toastList.filter((t) => t.id !== toastId));
})
);
render(<RenderToastList toasts$={toastObservable$.asObservable() as any} />);
fireEvent.click(screen.getByLabelText('Clear all toast notifications'));
act(() => {
// This is so that the toast fade out animation succesfully runs,
// only after this is the dismiss method invoked
jest.runOnlyPendingTimers();
});
expect(sharedProps.dismissToast).toHaveBeenCalledTimes(
UNIQUE_TOASTS_COUNT * REPEATED_COUNT_PER_UNIQUE_TOAST
);
expect(onDimissReporterSpy).toHaveBeenCalledTimes(UNIQUE_TOASTS_COUNT);
new Array(UNIQUE_TOASTS_COUNT).forEach((_, idx) => {
expect(onDimissReporterSpy).toHaveBeenCalledWith(
expect.objectContaining({
toastMessage: `${dummyToastTitle}_${idx}`,
recurrenceCount: REPEATED_COUNT_PER_UNIQUE_TOAST,
})
);
});
expect(screen.queryByLabelText('Notification')).toBeNull();
});
});

View file

@ -7,16 +7,19 @@
*/
import { EuiGlobalToastList, EuiGlobalToastListToast as EuiToast } from '@elastic/eui';
import React from 'react';
import { Observable, type Subscription } from 'rxjs';
import React, { useEffect, useState, type FunctionComponent, useCallback } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Observable } from 'rxjs';
import { i18n } from '@kbn/i18n';
import type { Toast } from '@kbn/core-notifications-browser';
import { MountWrapper } from '@kbn/core-mount-utils-browser-internal';
import { deduplicateToasts, ToastWithRichTitle } from './deduplicate_toasts';
import { EventReporter } from './telemetry';
interface Props {
toasts$: Observable<Toast[]>;
reportEvent: EventReporter;
dismissToast: (toastId: string) => void;
}
@ -31,50 +34,77 @@ const convertToEui = (toast: ToastWithRichTitle): EuiToast => ({
text: toast.text instanceof Function ? <MountWrapper mount={toast.text} /> : toast.text,
});
export class GlobalToastList extends React.Component<Props, State> {
public state: State = {
toasts: [],
idToToasts: {},
};
export const GlobalToastList: FunctionComponent<Props> = ({
toasts$,
dismissToast,
reportEvent,
}) => {
const [toasts, setToasts] = useState<State['toasts']>([]);
const [idToToasts, setIdToToasts] = useState<State['idToToasts']>({});
private subscription?: Subscription;
const reportToastDismissal = useCallback(
(representedToasts: State['idToToasts'][number]) => {
// Select the first duplicate toast within the represented toast group
// given it's identical to all other recurring ones within it's group
const firstDuplicateToast = representedToasts[0];
public componentDidMount() {
this.subscription = this.props.toasts$.subscribe((redundantToastList) => {
const { toasts, idToToasts } = deduplicateToasts(redundantToastList);
this.setState({ toasts, idToToasts });
if (
representedToasts.length > 1 &&
firstDuplicateToast.color !== 'success' &&
firstDuplicateToast.title
) {
reportEvent.onDismissToast({
toastMessage:
firstDuplicateToast.title instanceof Function
? renderToStaticMarkup(<MountWrapper mount={firstDuplicateToast.title} />)
: firstDuplicateToast.title,
recurrenceCount: representedToasts.length,
toastMessageType: firstDuplicateToast.color,
});
}
},
[reportEvent]
);
useEffect(() => {
const subscription = toasts$.subscribe((redundantToastList) => {
const { toasts: reducedToasts, idToToasts: reducedIdToasts } =
deduplicateToasts(redundantToastList);
setIdToToasts(reducedIdToasts);
setToasts(reducedToasts);
});
}
public componentWillUnmount() {
if (this.subscription) {
this.subscription.unsubscribe();
}
}
return () => subscription.unsubscribe();
}, [reportEvent, toasts$]);
private closeToastsRepresentedById(id: string) {
const representedToasts = this.state.idToToasts[id];
if (representedToasts) {
representedToasts.forEach((toast) => this.props.dismissToast(toast.id));
}
}
const closeToastsRepresentedById = useCallback(
({ id }: EuiToast) => {
const representedToasts = idToToasts[id];
public render() {
return (
<EuiGlobalToastList
aria-label={i18n.translate('core.notifications.globalToast.ariaLabel', {
defaultMessage: 'Notification message list',
})}
data-test-subj="globalToastList"
toasts={this.state.toasts.map(convertToEui)}
dismissToast={({ id }) => this.closeToastsRepresentedById(id)}
/**
* This prop is overridden by the individual toasts that are added.
* Use `Infinity` here so that it's obvious a timeout hasn't been
* provided in development.
*/
toastLifeTimeMs={Infinity}
/>
);
}
}
if (representedToasts) {
representedToasts.forEach((toast) => dismissToast(toast.id));
reportToastDismissal(representedToasts);
}
},
[dismissToast, idToToasts, reportToastDismissal]
);
return (
<EuiGlobalToastList
aria-label={i18n.translate('core.notifications.globalToast.ariaLabel', {
defaultMessage: 'Notification message list',
})}
data-test-subj="globalToastList"
toasts={toasts.map(convertToEui)}
dismissToast={closeToastsRepresentedById}
/**
* This prop is overridden by the individual toasts that are added.
* Use `Infinity` here so that it's obvious a timeout hasn't been
* provided in development.
*/
toastLifeTimeMs={Infinity}
/>
);
};

View file

@ -0,0 +1,44 @@
/*
* 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 { ComponentProps } from 'react';
import { EuiToast } from '@elastic/eui';
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import { EventMetric, FieldType } from './event_types';
type ToastMessageType = Exclude<ComponentProps<typeof EuiToast>['color'], 'success'>;
interface EventPayload {
[FieldType.RECURRENCE_COUNT]: number;
[FieldType.TOAST_MESSAGE]: string;
[FieldType.TOAST_MESSAGE_TYPE]: ToastMessageType;
}
export class EventReporter {
private reportEvent: AnalyticsServiceStart['reportEvent'];
constructor({ analytics }: { analytics: AnalyticsServiceStart }) {
this.reportEvent = analytics.reportEvent;
}
onDismissToast({
recurrenceCount,
toastMessage,
toastMessageType,
}: {
toastMessage: string;
recurrenceCount: number;
toastMessageType: ToastMessageType;
}) {
this.reportEvent<EventPayload>(EventMetric.TOAST_DISMISSED, {
[FieldType.RECURRENCE_COUNT]: recurrenceCount,
[FieldType.TOAST_MESSAGE]: toastMessage,
[FieldType.TOAST_MESSAGE_TYPE]: toastMessageType,
});
}
}

View file

@ -0,0 +1,60 @@
/*
* 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 RootSchema, type EventTypeOpts } from '@kbn/analytics-client';
export enum EventMetric {
TOAST_DISMISSED = 'global_toast_list_toast_dismissed',
}
export enum FieldType {
RECURRENCE_COUNT = 'toast_deduplication_count',
TOAST_MESSAGE = 'toast_message',
TOAST_MESSAGE_TYPE = 'toast_message_type',
}
const fields: Record<FieldType, RootSchema<Record<string, unknown>>> = {
[FieldType.TOAST_MESSAGE]: {
[FieldType.TOAST_MESSAGE]: {
type: 'keyword',
_meta: {
description: 'toast message text',
optional: false,
},
},
},
[FieldType.RECURRENCE_COUNT]: {
[FieldType.RECURRENCE_COUNT]: {
type: 'long',
_meta: {
description: 'recurrence count for particular toast message',
optional: false,
},
},
},
[FieldType.TOAST_MESSAGE_TYPE]: {
[FieldType.TOAST_MESSAGE_TYPE]: {
type: 'keyword',
_meta: {
description: 'toast message type',
optional: false,
},
},
},
};
export const eventTypes: Array<EventTypeOpts<Record<string, unknown>>> = [
{
eventType: EventMetric.TOAST_DISMISSED,
schema: {
...fields[FieldType.TOAST_MESSAGE],
...fields[FieldType.RECURRENCE_COUNT],
...fields[FieldType.TOAST_MESSAGE_TYPE],
},
},
];

View file

@ -0,0 +1,10 @@
/*
* 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.
*/
export { EventReporter } from './event_reporter';
export { eventTypes } from './event_types';

View file

@ -13,6 +13,8 @@ import { ToastsApi } from './toasts_api';
import { overlayServiceMock } from '@kbn/core-overlays-browser-mocks';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { EventReporter } from './telemetry';
const mockI18n: any = {
Context: function I18nContext() {
@ -22,6 +24,9 @@ const mockI18n: any = {
const mockOverlays = overlayServiceMock.createStartContract();
const mockTheme = themeServiceMock.createStartContract();
const mockAnalytics = analyticsServiceMock.createAnalyticsServiceStart();
const eventReporter = new EventReporter({ analytics: mockAnalytics });
describe('#setup()', () => {
it('returns a ToastsApi', () => {
@ -41,7 +46,13 @@ describe('#start()', () => {
expect(mockReactDomRender).not.toHaveBeenCalled();
toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() });
toasts.start({ i18n: mockI18n, theme: mockTheme, targetDomElement, overlays: mockOverlays });
toasts.start({
i18n: mockI18n,
theme: mockTheme,
targetDomElement,
overlays: mockOverlays,
eventReporter,
});
expect(mockReactDomRender.mock.calls).toMatchSnapshot();
});
@ -53,7 +64,13 @@ describe('#start()', () => {
toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() })
).toBeInstanceOf(ToastsApi);
expect(
toasts.start({ i18n: mockI18n, theme: mockTheme, targetDomElement, overlays: mockOverlays })
toasts.start({
i18n: mockI18n,
theme: mockTheme,
targetDomElement,
overlays: mockOverlays,
eventReporter,
})
).toBeInstanceOf(ToastsApi);
});
});
@ -65,7 +82,13 @@ describe('#stop()', () => {
const toasts = new ToastsService();
toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() });
toasts.start({ i18n: mockI18n, theme: mockTheme, targetDomElement, overlays: mockOverlays });
toasts.start({
i18n: mockI18n,
theme: mockTheme,
targetDomElement,
overlays: mockOverlays,
eventReporter,
});
expect(mockReactDomUnmount).not.toHaveBeenCalled();
toasts.stop();
@ -84,7 +107,13 @@ describe('#stop()', () => {
const toasts = new ToastsService();
toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() });
toasts.start({ i18n: mockI18n, theme: mockTheme, targetDomElement, overlays: mockOverlays });
toasts.start({
i18n: mockI18n,
theme: mockTheme,
targetDomElement,
overlays: mockOverlays,
eventReporter,
});
toasts.stop();
expect(targetDomElement.childNodes).toHaveLength(0);
});

View file

@ -16,6 +16,7 @@ import type { OverlayStart } from '@kbn/core-overlays-browser';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { GlobalToastList } from './global_toast_list';
import { ToastsApi } from './toasts_api';
import { EventReporter } from './telemetry';
interface SetupDeps {
uiSettings: IUiSettingsClient;
@ -25,6 +26,7 @@ interface StartDeps {
i18n: I18nStart;
overlays: OverlayStart;
theme: ThemeServiceStart;
eventReporter: EventReporter;
targetDomElement: HTMLElement;
}
@ -37,7 +39,7 @@ export class ToastsService {
return this.api!;
}
public start({ i18n, overlays, theme, targetDomElement }: StartDeps) {
public start({ eventReporter, i18n, overlays, theme, targetDomElement }: StartDeps) {
this.api!.start({ overlays, i18n, theme });
this.targetDomElement = targetDomElement;
@ -46,6 +48,7 @@ export class ToastsService {
<GlobalToastList
dismissToast={(toastId: string) => this.api!.remove(toastId)}
toasts$={this.api!.get$()}
reportEvent={eventReporter}
/>
</KibanaRenderContextProvider>,
targetDomElement

View file

@ -29,6 +29,9 @@
"@kbn/core-theme-browser-mocks",
"@kbn/core-mount-utils-browser",
"@kbn/react-kibana-context-render",
"@kbn/core-analytics-browser",
"@kbn/core-analytics-browser-mocks",
"@kbn/analytics-client",
],
"exclude": [
"target/**/*",

View file

@ -460,6 +460,7 @@ describe('#start()', () => {
overlays: expect.any(Object),
theme: expect.any(Object),
targetDomElement: expect.any(HTMLElement),
analytics: expect.any(Object),
});
});

View file

@ -239,7 +239,7 @@ export class CoreSystem {
this.chrome.setup({ analytics });
const uiSettings = this.uiSettings.setup({ http, injectedMetadata });
const settings = this.settings.setup({ http, injectedMetadata });
const notifications = this.notifications.setup({ uiSettings });
const notifications = this.notifications.setup({ uiSettings, analytics });
const customBranding = this.customBranding.setup({ injectedMetadata });
const application = this.application.setup({ http, analytics });
@ -305,6 +305,7 @@ export class CoreSystem {
targetDomElement: overlayTargetDomElement,
});
const notifications = await this.notifications.start({
analytics,
i18n,
overlays,
theme,