diff --git a/packages/core/notifications/core-notifications-browser-internal/src/notifications_service.ts b/packages/core/notifications/core-notifications-browser-internal/src/notifications_service.ts
index b7babd0cb433d..3df6ac89e03d3 100644
--- a/packages/core/notifications/core-notifications-browser-internal/src/notifications_service.ts
+++ b/packages/core/notifications/core-notifications-browser-internal/src/notifications_service.ts
@@ -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,
diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/global_toast_list.test.tsx.snap b/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/global_toast_list.test.tsx.snap
deleted file mode 100644
index d9dc9f6c7b13d..0000000000000
--- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/global_toast_list.test.tsx.snap
+++ /dev/null
@@ -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`] = `
-,
- "toastLifeTimeMs": 5000,
- },
- ]
- }
-/>
-`;
-
-exports[`global_toast_list with duplicate elements, using MountPoints renders the all separate elements element: euiToastList 1`] = `
-,
- "toastLifeTimeMs": 5000,
- },
- Object {
- "id": "1",
- "text": "You've got mail!",
- "title": ,
- "toastLifeTimeMs": 5000,
- },
- Object {
- "id": "2",
- "text": "You've got mail!",
- "title": ,
- "toastLifeTimeMs": 5000,
- },
- Object {
- "id": "3",
- "text": "You've got mail!",
- "title": ,
- "toastLifeTimeMs": 5000,
- },
- ]
- }
-/>
-`;
-
-exports[`global_toast_list with duplicate elements, using MountPoints renders the all separate elements element: globalToastList 1`] = `
-,
- "toastLifeTimeMs": 5000,
- },
- Object {
- "id": "1",
- "text": "You've got mail!",
- "title": ,
- "toastLifeTimeMs": 5000,
- },
- Object {
- "id": "2",
- "text": "You've got mail!",
- "title": ,
- "toastLifeTimeMs": 5000,
- },
- Object {
- "id": "3",
- "text": "You've got mail!",
- "title": ,
- "toastLifeTimeMs": 5000,
- },
- ]
- }
-/>
-`;
-
-exports[`renders matching snapshot 1`] = `
-
-`;
diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/toasts_service.test.tsx.snap b/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/toasts_service.test.tsx.snap
index fbb22caac4cd8..fa435d05d72c7 100644
--- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/toasts_service.test.tsx.snap
+++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/toasts_service.test.tsx.snap
@@ -19,6 +19,11 @@ Array [
>
= {}) {
- return ;
+const sharedProps = {
+ toasts$: EMPTY,
+ dismissToast: jest.fn(),
+ reportEvent: new EventReporter({ analytics: mockAnalytics }),
+};
+
+function RenderToastList(props: Partial> = {}) {
+ return ;
}
-it('renders matching snapshot', () => {
- expect(shallow(render())).toMatchSnapshot();
+const dummyToastText = `You've got mail!`;
+const dummyToastTitle = `AOL Notifications`;
+
+const createMockToast = (id: any, type?: ComponentProps['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(subscribeSpy),
- });
+ const { unmount } = render((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 ', () => {
- const el = shallow(
- render({
- toasts$: from([[], [{ id: '1' }], [{ id: '1' }, { id: '2' }]]) as any,
- })
- );
+it('uses the latest value from toasts$ passed to to render the right number of toasts', () => {
+ const toastObservable$ = new BehaviorSubject([{ id: '1' }, { id: '2' }]);
+
+ render();
+
+ expect(screen.getAllByLabelText('Notification')).toHaveLength(2);
+
+ act(() => {
+ toastObservable$.next([...toastObservable$.getValue(), { id: '3' }]);
+ });
- expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([{ id: '1' }, { id: '2' }]);
+ 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 (
+ createMockToast(idx)),
+ ]) as any
+ }
+ />
+ );
+ }
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
});
- const globalToastList = shallow(
- render({
- toasts$: from([[toast(0), toast(1), toast(2), toast(3)]]) as any,
- })
- );
+ it('renders the toast list with a single toast when toasts matching deduplication heuristics are passed', () => {
+ render();
- const euiToastList = globalToastList.find(EuiGlobalToastList);
- const toastsProp = euiToastList.prop('toasts');
+ const { 0: firstToast, length: toastCount } = screen.getAllByLabelText('Notification');
- it('renders the list with a single element', () => {
- expect(toastsProp).toBeDefined();
- expect(toastsProp).toHaveLength(1);
- expect(euiToastList).toMatchSnapshot();
+ expect(toastCount).toEqual(1);
+
+ expect(screen.getAllByText(dummyToastText)).toHaveLength(1);
+
+ expect(firstToast.querySelector('.euiNotificationBadge')?.innerHTML).toEqual('4');
});
- it('renders the single toast with the common text', () => {
- const firstRenderedToast = toastsProp![0];
- expect(firstRenderedToast.text).toBe(dummyText);
+ 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,
+ });
+
+ render(
+
+ createMockToastWithMountPoints(idx)
+ ),
+ ]) as any
+ }
+ />
+ );
+
+ const renderedToasts = screen.getAllByText(dummyToastText);
+
+ expect(renderedToasts).toHaveLength(TOAST_DUPLICATE_COUNT);
});
- it(`calls all toast's dismiss when closed`, () => {
- const firstRenderedToast = toastsProp![0];
- const dismissToast = globalToastList.prop('dismissToast');
- dismissToast(firstRenderedToast);
+ it(`when a represented toast is closed, the provided dismiss action is called for all its internal toasts`, () => {
+ render();
+
+ 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(mockDismissToast).toHaveBeenCalledTimes(4);
- expect(mockDismissToast).toHaveBeenCalledWith('0');
- expect(mockDismissToast).toHaveBeenCalledWith('1');
- expect(mockDismissToast).toHaveBeenCalledWith('2');
- expect(mockDismissToast).toHaveBeenCalledWith('3');
+ 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();
+ });
+
+ it('does not invoke the reportEvent method when there is no recurring toast', async () => {
+ const onDimissReporterSpy = jest.spyOn(sharedProps.reportEvent, 'onDismissToast');
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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(
+ 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();
+
+ 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);
- const euiToastList = globalToastList.find(EuiGlobalToastList);
- const toastsProp = euiToastList.prop('toasts');
+ new Array(UNIQUE_TOASTS_COUNT).forEach((_, idx) => {
+ expect(onDimissReporterSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ toastMessage: `${dummyToastTitle}_${idx}`,
+ recurrenceCount: REPEATED_COUNT_PER_UNIQUE_TOAST,
+ })
+ );
+ });
- it('renders the all separate elements element', () => {
- expect(toastsProp).toBeDefined();
- expect(toastsProp).toHaveLength(4);
- expect(euiToastList).toMatchSnapshot('euiToastList');
- expect(globalToastList).toMatchSnapshot('globalToastList');
+ expect(screen.queryByLabelText('Notification')).toBeNull();
});
});
diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/global_toast_list.tsx b/packages/core/notifications/core-notifications-browser-internal/src/toasts/global_toast_list.tsx
index e0a5d631d3776..5218afd7c6209 100644
--- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/global_toast_list.tsx
+++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/global_toast_list.tsx
@@ -7,16 +7,18 @@
*/
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 { 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;
+ reportEvent: EventReporter;
dismissToast: (toastId: string) => void;
}
@@ -31,50 +33,73 @@ const convertToEui = (toast: ToastWithRichTitle): EuiToast => ({
text: toast.text instanceof Function ? : toast.text,
});
-export class GlobalToastList extends React.Component {
- public state: State = {
- toasts: [],
- idToToasts: {},
- };
+export const GlobalToastList: FunctionComponent = ({
+ toasts$,
+ dismissToast,
+ reportEvent,
+}) => {
+ const [toasts, setToasts] = useState([]);
+ const [idToToasts, setIdToToasts] = useState({});
- 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({
+ 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 (
- 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 (
+
+ );
+};
diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_reporter.ts b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_reporter.ts
new file mode 100644
index 0000000000000..135dcc5741fb8
--- /dev/null
+++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_reporter.ts
@@ -0,0 +1,40 @@
+/*
+ * 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['color'], 'success'>;
+
+interface EventPayload {
+ [FieldType.RECURRENCE_COUNT]: number;
+ [FieldType.TOAST_MESSAGE_TYPE]: ToastMessageType;
+}
+
+export class EventReporter {
+ private reportEvent: AnalyticsServiceStart['reportEvent'];
+
+ constructor({ analytics }: { analytics: AnalyticsServiceStart }) {
+ this.reportEvent = analytics.reportEvent;
+ }
+
+ onDismissToast({
+ recurrenceCount,
+ toastMessageType,
+ }: {
+ recurrenceCount: number;
+ toastMessageType: ToastMessageType;
+ }) {
+ this.reportEvent(EventMetric.TOAST_DISMISSED, {
+ [FieldType.RECURRENCE_COUNT]: recurrenceCount,
+ [FieldType.TOAST_MESSAGE_TYPE]: toastMessageType,
+ });
+ }
+}
diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_types.ts b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_types.ts
new file mode 100644
index 0000000000000..739d3d6b0b9f0
--- /dev/null
+++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_types.ts
@@ -0,0 +1,49 @@
+/*
+ * 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_TYPE = 'toast_message_type',
+}
+
+const fields: Record>> = {
+ [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>> = [
+ {
+ eventType: EventMetric.TOAST_DISMISSED,
+ schema: {
+ ...fields[FieldType.RECURRENCE_COUNT],
+ ...fields[FieldType.TOAST_MESSAGE_TYPE],
+ },
+ },
+];
diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/index.ts b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/index.ts
new file mode 100644
index 0000000000000..dad98bcbd54be
--- /dev/null
+++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/index.ts
@@ -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';
diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.test.tsx b/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.test.tsx
index 4e4ef668e9778..6509c75b84533 100644
--- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.test.tsx
+++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.test.tsx
@@ -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);
});
diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.tsx b/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.tsx
index 9656b6764b715..63ede7bcb906e 100644
--- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.tsx
+++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.tsx
@@ -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 {
this.api!.remove(toastId)}
toasts$={this.api!.get$()}
+ reportEvent={eventReporter}
/>
,
targetDomElement
diff --git a/packages/core/notifications/core-notifications-browser-internal/tsconfig.json b/packages/core/notifications/core-notifications-browser-internal/tsconfig.json
index 09392c1805f8f..0250d4b80488b 100644
--- a/packages/core/notifications/core-notifications-browser-internal/tsconfig.json
+++ b/packages/core/notifications/core-notifications-browser-internal/tsconfig.json
@@ -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/**/*",
diff --git a/packages/core/root/core-root-browser-internal/src/core_system.test.ts b/packages/core/root/core-root-browser-internal/src/core_system.test.ts
index d77ccaedb5279..b4cdd4b1d965b 100644
--- a/packages/core/root/core-root-browser-internal/src/core_system.test.ts
+++ b/packages/core/root/core-root-browser-internal/src/core_system.test.ts
@@ -460,6 +460,7 @@ describe('#start()', () => {
overlays: expect.any(Object),
theme: expect.any(Object),
targetDomElement: expect.any(HTMLElement),
+ analytics: expect.any(Object),
});
});
diff --git a/packages/core/root/core-root-browser-internal/src/core_system.ts b/packages/core/root/core-root-browser-internal/src/core_system.ts
index cf9a172479b31..0980c84aab89f 100644
--- a/packages/core/root/core-root-browser-internal/src/core_system.ts
+++ b/packages/core/root/core-root-browser-internal/src/core_system.ts
@@ -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,