[SecuritySolution] Add UI tracking for grouping options (#151708)

## Summary

Relevant issues:
Overall telemetry: https://github.com/elastic/kibana/issues/144945
Alerts telemetry: https://github.com/elastic/kibana/issues/150656

Preview dashboard:

40755fc0-b454-11ed-a6e6-d32d2209b7b7?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now-7d%2Fd,to:now))

**UI counter added** - overall counts
1. alerts_table_group_by_{tableId}_{groupByField} -
`alerts_table_group_by_alerts-page_host.name ` triggered on grouping
option changed.
2. alerts_table_toggled_{on|off}_{tableId}_group-{groupNumber} -
`alerts_table_toggled_off_alerts-page_group-0` sent when grouped alerts
toggled
3. alerts_table_{tableId}_group-{groupNumber}_mark-{status} -
`alerts_table_alerts-page_group-0_mark-open` sent when group actions
taken

**Event based telemetry added** - extra info from `properties` can be
aggregated / visualised
1. Alerts grouping take action - sent when group actions taken
2. Alerts grouping toggled - sent when grouped alerts toggled
3. Alerts grouping changed - triggered on grouping option changed

[Example
events](9b0f2080-bcd1-11ed-a6e6-d32d2209b7b7?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now-7d%2Fd,to:now))&_a=(columns:!(context.applicationId,properties,properties.groupingId,properties.groupNumber,properties.status,event_type,properties.tableId),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:c5dc7cd0-2950-4e51-b428-d0451b1b8d9d,key:context.applicationId,negate:!f,params:(query:securitySolutionUI),type:phrase),query:(match_phrase:(context.applicationId:securitySolutionUI)))),grid:(),hideChart:!f,index:c5dc7cd0-2950-4e51-b428-d0451b1b8d9d,interval:auto,query:(language:kuery,query:'event_type%20:%20Alerts%20Grouping*'),sort:!(!(timestamp,desc))))

**Steps to verify:**
1. add telemetry.optIn: true to kibana.dev.yml
2. Visit alerts page or rule details page, change the grouping , toggle
each group, and take actions to grouped alerts
3. Usually the event would be sent every hour to
[staging](9b0f2080-bcd1-11ed-a6e6-d32d2209b7b7?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now-7d%2Fd,to:now))&_a=(columns:!(context.applicationId,properties,properties.groupingId,properties.groupNumber,properties.status,event_type,properties.tableId),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:c5dc7cd0-2950-4e51-b428-d0451b1b8d9d,key:context.applicationId,negate:!f,params:(query:securitySolutionUI),type:phrase),query:(match_phrase:(context.applicationId:securitySolutionUI)))),grid:(),hideChart:!f,index:c5dc7cd0-2950-4e51-b428-d0451b1b8d9d,interval:auto,query:(language:kuery,query:'event_type%20:%20Alerts%20Grouping*'),sort:!(!(timestamp,desc)))),
if not, visit staging again on the next day.

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

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Pablo Neves Machado <pablo.nevesmachado@elastic.co>
This commit is contained in:
Angela Chuang 2023-03-10 20:26:40 +00:00 committed by GitHub
parent 6e3a34fdda
commit a564ca5fe3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 719 additions and 24 deletions

View file

@ -12,6 +12,7 @@ import React from 'react';
const onGroupChange = jest.fn();
const testProps = {
groupingId: 'test-grouping-id',
fields: [
{
name: 'kibana.alert.rule.name',

View file

@ -20,6 +20,7 @@ import { StyledContextMenu, StyledEuiButtonEmpty } from '../styles';
export interface GroupSelectorProps {
'data-test-subj'?: string;
fields: FieldSpec[];
groupingId: string;
groupSelected: string;
onGroupChange: (groupSelection: string) => void;
options: Array<{ key: string; label: string }>;

View file

@ -11,9 +11,12 @@ import React from 'react';
import { I18nProvider } from '@kbn/i18n-react';
import { Grouping } from './grouping';
import { createGroupFilter } from './accordion_panel/helpers';
import { METRIC_TYPE } from '@kbn/analytics';
import { getTelemetryEvent } from '../telemetry/const';
const renderChildComponent = jest.fn();
const takeActionItems = jest.fn();
const mockTracker = jest.fn();
const rule1Name = 'Rule 1 name';
const rule1Desc = 'Rule 1 description';
const rule2Name = 'Rule 2 name';
@ -98,6 +101,7 @@ const testProps = {
value: 2,
},
},
groupingId: 'test-grouping-id',
isLoading: false,
pagination: {
pageIndex: 0,
@ -109,6 +113,7 @@ const testProps = {
renderChildComponent,
selectedGroup: 'kibana.alert.rule.name',
takeActionItems,
tracker: mockTracker,
};
describe('grouping container', () => {
@ -171,4 +176,33 @@ describe('grouping container', () => {
createGroupFilter(testProps.selectedGroup, rule2Name)
);
});
it('Send Telemetry when each group is clicked', () => {
const { getAllByTestId } = render(
<I18nProvider>
<Grouping {...testProps} />
</I18nProvider>
);
const group1 = within(getAllByTestId('grouping-accordion')[0]).getAllByRole('button')[0];
fireEvent.click(group1);
expect(mockTracker).toHaveBeenNthCalledWith(
1,
METRIC_TYPE.CLICK,
getTelemetryEvent.groupToggled({
isOpen: true,
groupingId: testProps.groupingId,
groupNumber: 0,
})
);
fireEvent.click(group1);
expect(mockTracker).toHaveBeenNthCalledWith(
2,
METRIC_TYPE.CLICK,
getTelemetryEvent.groupToggled({
isOpen: false,
groupingId: testProps.groupingId,
groupNumber: 0,
})
);
});
});

View file

@ -15,6 +15,7 @@ import {
} from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import React, { useMemo, useState } from 'react';
import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
import { defaultUnit, firstNonNullValue } from '../helpers';
import { createGroupFilter } from './accordion_panel/helpers';
import type { BadgeMetric, CustomMetric } from './accordion_panel';
@ -24,15 +25,23 @@ import { EmptyGroupingComponent } from './empty_results_panel';
import { groupingContainerCss, countCss } from './styles';
import { GROUPS_UNIT } from './translations';
import type { GroupingAggregation, GroupingFieldTotalAggregation, RawBucket } from './types';
import { getTelemetryEvent } from '../telemetry/const';
export interface GroupingProps<T> {
badgeMetricStats?: (fieldBucket: RawBucket<T>) => BadgeMetric[];
customMetricStats?: (fieldBucket: RawBucket<T>) => CustomMetric[];
data?: GroupingAggregation<T> & GroupingFieldTotalAggregation;
groupingId: string;
groupPanelRenderer?: (fieldBucket: RawBucket<T>) => JSX.Element | undefined;
groupSelector?: JSX.Element;
inspectButton?: JSX.Element;
isLoading: boolean;
onToggleCallback?: (params: {
isOpen: boolean;
groupName?: string | undefined;
groupNumber: number;
groupingId: string;
}) => void;
pagination: {
pageIndex: number;
pageSize: number;
@ -42,7 +51,12 @@ export interface GroupingProps<T> {
};
renderChildComponent: (groupFilter: Filter[]) => React.ReactNode;
selectedGroup: string;
takeActionItems: (groupFilters: Filter[]) => JSX.Element[];
takeActionItems: (groupFilters: Filter[], groupNumber: number) => JSX.Element[];
tracker?: (
type: UiCounterMetricType,
event: string | string[],
count?: number | undefined
) => void;
unit?: (n: number) => string;
}
@ -50,14 +64,17 @@ const GroupingComponent = <T,>({
badgeMetricStats,
customMetricStats,
data,
groupingId,
groupPanelRenderer,
groupSelector,
inspectButton,
isLoading,
onToggleCallback,
pagination,
renderChildComponent,
selectedGroup,
takeActionItems,
tracker,
unit = defaultUnit,
}: GroupingProps<T>) => {
const [trigger, setTrigger] = useState<
@ -77,9 +94,9 @@ const GroupingComponent = <T,>({
const groupPanels = useMemo(
() =>
data?.stackByMultipleFields0?.buckets?.map((groupBucket) => {
data?.stackByMultipleFields0?.buckets?.map((groupBucket, groupNumber) => {
const group = firstNonNullValue(groupBucket.key);
const groupKey = `group0-${group}`;
const groupKey = `group-${groupNumber}-${group}`;
return (
<span key={groupKey}>
@ -87,7 +104,10 @@ const GroupingComponent = <T,>({
extraAction={
<GroupStats
bucket={groupBucket}
takeActionItems={takeActionItems(createGroupFilter(selectedGroup, group))}
takeActionItems={takeActionItems(
createGroupFilter(selectedGroup, group),
groupNumber
)}
badgeMetricStats={badgeMetricStats && badgeMetricStats(groupBucket)}
customMetricStats={customMetricStats && customMetricStats(groupBucket)}
/>
@ -97,6 +117,11 @@ const GroupingComponent = <T,>({
groupPanelRenderer={groupPanelRenderer && groupPanelRenderer(groupBucket)}
isLoading={isLoading}
onToggleGroup={(isOpen) => {
// built-in telemetry: UI-counter
tracker?.(
METRIC_TYPE.CLICK,
getTelemetryEvent.groupToggled({ isOpen, groupingId, groupNumber })
);
setTrigger({
// ...trigger, -> this change will keep only one group at a time expanded and one table displayed
[groupKey]: {
@ -104,6 +129,7 @@ const GroupingComponent = <T,>({
selectedBucket: groupBucket,
},
});
onToggleCallback?.({ isOpen, groupName: group, groupNumber, groupingId });
}}
renderChildComponent={
trigger[groupKey] && trigger[groupKey].state === 'open'
@ -121,10 +147,13 @@ const GroupingComponent = <T,>({
customMetricStats,
data?.stackByMultipleFields0?.buckets,
groupPanelRenderer,
groupingId,
isLoading,
onToggleCallback,
renderChildComponent,
selectedGroup,
takeActionItems,
tracker,
trigger,
]
);

View file

@ -10,6 +10,7 @@ import { act, renderHook } from '@testing-library/react-hooks';
import { useGetGroupSelector } from './use_get_group_selector';
import { initialState } from './state';
import { ActionType, defaultGroup } from '..';
import { METRIC_TYPE } from '@kbn/analytics';
const defaultGroupingOptions = [
{ label: 'ruleName', key: 'kibana.alert.rule.name' },
@ -25,6 +26,8 @@ const defaultArgs = {
fields: [],
groupingId,
groupingState: initialState,
tracker: jest.fn(),
onGroupChangeCallback: jest.fn(),
};
const customField = 'custom.field';
describe('useGetGroupSelector', () => {
@ -123,6 +126,54 @@ describe('useGetGroupSelector', () => {
expect(dispatch).toHaveBeenCalledTimes(2);
});
it('On group change, sends telemetry', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroup: 'host.name',
},
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
groupById: testGroup,
},
},
});
act(() => result.current.props.onGroupChange(customField));
expect(defaultArgs.tracker).toHaveBeenCalledTimes(1);
expect(defaultArgs.tracker).toHaveBeenCalledWith(
METRIC_TYPE.CLICK,
`alerts_table_group_by_test-table_${customField}`
);
});
it('On group change, executes callback', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroup: 'host.name',
},
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
groupById: testGroup,
},
},
});
act(() => result.current.props.onGroupChange(customField));
expect(defaultArgs.onGroupChangeCallback).toHaveBeenCalledTimes(1);
expect(defaultArgs.onGroupChangeCallback).toHaveBeenCalledWith({
tableId: groupingId,
groupByField: customField,
});
});
it('On group change to custom field, updates options', () => {
const testGroup = {
[groupingId]: {

View file

@ -9,10 +9,12 @@
import type { FieldSpec } from '@kbn/data-views-plugin/common';
import { useCallback, useEffect } from 'react';
import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
import { getGroupSelector, isNoneGroup } from '../..';
import { groupActions, groupByIdSelector } from './state';
import type { GroupOption } from './types';
import { Action, defaultGroup, GroupMap } from './types';
import { getTelemetryEvent } from '../telemetry/const';
export interface UseGetGroupSelectorArgs {
defaultGroupingOptions: GroupOption[];
@ -20,6 +22,12 @@ export interface UseGetGroupSelectorArgs {
fields: FieldSpec[];
groupingId: string;
groupingState: GroupMap;
onGroupChangeCallback?: (param: { groupByField: string; tableId: string }) => void;
tracker: (
type: UiCounterMetricType,
event: string | string[],
count?: number | undefined
) => void;
}
export const useGetGroupSelector = ({
@ -28,6 +36,8 @@ export const useGetGroupSelector = ({
fields,
groupingId,
groupingState,
onGroupChangeCallback,
tracker,
}: UseGetGroupSelectorArgs) => {
const { activeGroup: selectedGroup, options } =
groupByIdSelector({ groups: groupingState }, groupingId) ?? defaultGroup;
@ -61,6 +71,14 @@ export const useGetGroupSelector = ({
setGroupsActivePage(0);
setSelectedGroup(groupSelection);
// built-in telemetry: UI-counter
tracker?.(
METRIC_TYPE.CLICK,
getTelemetryEvent.groupChanged({ groupingId, selected: groupSelection })
);
onGroupChangeCallback?.({ tableId: groupingId, groupByField: groupSelection });
// only update options if the new selection is a custom field
if (
!isNoneGroup(groupSelection) &&
@ -77,11 +95,14 @@ export const useGetGroupSelector = ({
},
[
defaultGroupingOptions,
groupingId,
onGroupChangeCallback,
options,
selectedGroup,
setGroupsActivePage,
setOptions,
setSelectedGroup,
tracker,
]
);
@ -106,6 +127,7 @@ export const useGetGroupSelector = ({
}, [defaultGroupingOptions, options.length, selectedGroup, setOptions]);
return getGroupSelector({
groupingId,
groupSelected: selectedGroup,
'data-test-subj': 'alerts-table-group-selector',
onGroupChange,

View file

@ -21,6 +21,7 @@ const defaultArgs = {
defaultGroupingOptions,
fields: [],
groupingId,
tracker: jest.fn(),
};
const groupingArgs = {
@ -36,7 +37,7 @@ const groupingArgs = {
renderChildComponent: jest.fn(),
runtimeMappings: {},
signalIndexName: 'test',
tableId: groupingId,
groupingId,
takeActionItems: jest.fn(),
to: '2020-07-08T08:20:18.966Z',
};

View file

@ -8,6 +8,7 @@
import { FieldSpec } from '@kbn/data-views-plugin/common';
import React, { useCallback, useMemo, useReducer } from 'react';
import { UiCounterMetricType } from '@kbn/analytics';
import { groupsReducerWithStorage, initialState } from './state/reducer';
import { GroupingProps, GroupSelectorProps } from '..';
import { useGroupingPagination } from './use_grouping_pagination';
@ -31,14 +32,21 @@ interface Grouping<T> {
interface GroupingArgs {
defaultGroupingOptions: GroupOption[];
fields: FieldSpec[];
groupingId: string;
onGroupChangeCallback?: (param: { groupByField: string; tableId: string }) => void;
tracker: (
type: UiCounterMetricType,
event: string | string[],
count?: number | undefined
) => void;
}
export const useGrouping = <T,>({
defaultGroupingOptions,
fields,
groupingId,
onGroupChangeCallback,
tracker,
}: GroupingArgs): Grouping<T> => {
const [groupingState, dispatch] = useReducer(groupsReducerWithStorage, initialState);
@ -53,6 +61,8 @@ export const useGrouping = <T,>({
fields,
groupingId,
groupingState,
onGroupChangeCallback,
tracker,
});
const pagination = useGroupingPagination({ groupingId, groupingState, dispatch });

View file

@ -0,0 +1,26 @@
/*
* 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.
*/
enum TELEMETRY_EVENT {
GROUP_TOGGLED = 'alerts_table_toggled_',
GROUPED_ALERTS = 'alerts_table_group_by_',
}
export const getTelemetryEvent = {
groupToggled: ({
isOpen,
groupingId,
groupNumber,
}: {
isOpen: boolean;
groupingId: string;
groupNumber: number;
}) =>
`${TELEMETRY_EVENT.GROUP_TOGGLED}${isOpen ? 'on' : 'off'}_${groupingId}_group-${groupNumber}`,
groupChanged: ({ groupingId, selected }: { groupingId: string; selected: string }) =>
`${TELEMETRY_EVENT.GROUPED_ALERTS}${groupingId}_${selected}`,
};

View file

@ -24,5 +24,6 @@
"@kbn/kibana-react-plugin",
"@kbn/shared-svg",
"@kbn/ui-theme",
"@kbn/analytics"
]
}

View file

@ -9,9 +9,13 @@ import type { UiCounterMetricType } from '@kbn/analytics';
import { METRIC_TYPE } from '@kbn/analytics';
import type { SetupPlugins } from '../../../types';
import type { AlertWorkflowStatus } from '../../types';
export { telemetryMiddleware } from './middleware';
export { METRIC_TYPE };
export * from './telemetry_client';
export * from './telemetry_service';
export * from './types';
type TrackFn = (type: UiCounterMetricType, event: string | string[], count?: number) => void;
@ -40,7 +44,6 @@ export enum TELEMETRY_EVENT {
SIEM_RULE_DISABLED = 'siem_rule_disabled',
CUSTOM_RULE_ENABLED = 'custom_rule_enabled',
CUSTOM_RULE_DISABLED = 'custom_rule_disabled',
// ML
SIEM_JOB_ENABLED = 'siem_job_enabled',
SIEM_JOB_DISABLED = 'siem_job_disabled',
@ -67,3 +70,15 @@ export enum TELEMETRY_EVENT {
BREADCRUMB = 'breadcrumb_',
LEGACY_NAVIGATION = 'legacy_navigation_',
}
export const getTelemetryEvent = {
groupedAlertsTakeAction: ({
tableId,
groupNumber,
status,
}: {
tableId: string;
groupNumber: number;
status: AlertWorkflowStatus;
}) => `alerts_table_${tableId}_group-${groupNumber}_mark-${status}`,
};

View file

@ -0,0 +1,14 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { TelemetryClientStart } from './types';
export const createTelemetryClientMock = (): jest.Mocked<TelemetryClientStart> => ({
reportAlertsGroupingChanged: jest.fn(),
reportAlertsGroupingToggled: jest.fn(),
reportAlertsGroupingTakeAction: jest.fn(),
});

View file

@ -0,0 +1,61 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { AnalyticsServiceSetup } from '@kbn/core-analytics-server';
import type {
TelemetryClientStart,
ReportAlertsGroupingChangedParams,
ReportAlertsGroupingToggledParams,
ReportAlertsTakeActionParams,
} from './types';
import { TelemetryEventTypes } from './types';
/**
* Client which aggregate all the available telemetry tracking functions
* for the plugin
*/
export class TelemetryClient implements TelemetryClientStart {
constructor(private analytics: AnalyticsServiceSetup) {}
public reportAlertsGroupingChanged = ({
tableId,
groupByField,
}: ReportAlertsGroupingChangedParams) => {
this.analytics.reportEvent(TelemetryEventTypes.AlertsGroupingChanged, {
tableId,
groupByField,
});
};
public reportAlertsGroupingToggled = ({
isOpen,
tableId,
groupNumber,
groupName,
}: ReportAlertsGroupingToggledParams) => {
this.analytics.reportEvent(TelemetryEventTypes.AlertsGroupingToggled, {
isOpen,
tableId,
groupNumber,
groupName,
});
};
public reportAlertsGroupingTakeAction = ({
tableId,
groupNumber,
status,
groupByField,
}: ReportAlertsTakeActionParams) => {
this.analytics.reportEvent(TelemetryEventTypes.AlertsGroupingTakeAction, {
tableId,
groupNumber,
status,
groupByField,
});
};
}

View file

@ -0,0 +1,102 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { TelemetryEvent } from './types';
import { TelemetryEventTypes } from './types';
const alertsGroupingToggledEvent: TelemetryEvent = {
eventType: TelemetryEventTypes.AlertsGroupingToggled,
schema: {
isOpen: {
type: 'boolean',
_meta: {
description: 'on or off',
optional: false,
},
},
tableId: {
type: 'text',
_meta: {
description: 'Table ID',
optional: false,
},
},
groupNumber: {
type: 'integer',
_meta: {
description: 'Group number',
optional: false,
},
},
groupName: {
type: 'keyword',
_meta: {
description: 'Group value',
optional: true,
},
},
},
};
const alertsGroupingChangedEvent: TelemetryEvent = {
eventType: TelemetryEventTypes.AlertsGroupingChanged,
schema: {
tableId: {
type: 'keyword',
_meta: {
description: 'Table ID',
optional: false,
},
},
groupByField: {
type: 'keyword',
_meta: {
description: 'Selected field',
optional: false,
},
},
},
};
const alertsGroupingTakeActionEvent: TelemetryEvent = {
eventType: TelemetryEventTypes.AlertsGroupingTakeAction,
schema: {
tableId: {
type: 'keyword',
_meta: {
description: 'Table ID',
optional: false,
},
},
groupNumber: {
type: 'integer',
_meta: {
description: 'Group number',
optional: false,
},
},
status: {
type: 'keyword',
_meta: {
description: 'Alert status',
optional: false,
},
},
groupByField: {
type: 'keyword',
_meta: {
description: 'Selected field',
optional: false,
},
},
},
};
export const telemetryEvents = [
alertsGroupingToggledEvent,
alertsGroupingChangedEvent,
alertsGroupingTakeActionEvent,
];

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createTelemetryClientMock } from './telemetry_client.mock';
export const createTelemetryServiceMock = () => createTelemetryClientMock();

View file

@ -0,0 +1,80 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { coreMock } from '@kbn/core/server/mocks';
import { telemetryEvents } from './telemetry_events';
import { TelemetryService } from './telemetry_service';
import { TelemetryEventTypes } from './types';
describe('TelemetryService', () => {
let service: TelemetryService;
beforeEach(() => {
service = new TelemetryService();
});
const getSetupParams = () => {
const mockCoreStart = coreMock.createSetup();
return {
analytics: mockCoreStart.analytics,
};
};
describe('#setup()', () => {
it('should register all the custom events', () => {
const setupParams = getSetupParams();
service.setup(setupParams);
expect(setupParams.analytics.registerEventType).toHaveBeenCalledTimes(telemetryEvents.length);
telemetryEvents.forEach((eventConfig, pos) => {
expect(setupParams.analytics.registerEventType).toHaveBeenNthCalledWith(
pos + 1,
eventConfig
);
});
});
});
describe('#start()', () => {
it('should return all the available tracking methods', () => {
const setupParams = getSetupParams();
service.setup(setupParams);
const telemetry = service.start();
expect(telemetry).toHaveProperty('reportAlertsGroupingChanged');
expect(telemetry).toHaveProperty('reportAlertsGroupingToggled');
expect(telemetry).toHaveProperty('reportAlertsGroupingTakeAction');
});
});
describe('#reportAlertsGroupingTakeAction', () => {
it('should report hosts entry click with properties', async () => {
const setupParams = getSetupParams();
service.setup(setupParams);
const telemetry = service.start();
telemetry.reportAlertsGroupingTakeAction({
tableId: 'test-groupingId',
groupNumber: 0,
status: 'closed',
groupByField: 'host.name',
});
expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1);
expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith(
TelemetryEventTypes.AlertsGroupingTakeAction,
{
tableId: 'test-groupingId',
groupNumber: 0,
status: 'closed',
groupByField: 'host.name',
}
);
});
});
});

View file

@ -0,0 +1,57 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { AnalyticsServiceSetup } from '@kbn/core-analytics-server';
import { of } from 'rxjs';
import type {
TelemetryServiceSetupParams,
TelemetryClientStart,
TelemetryEventParams,
} from './types';
import { telemetryEvents } from './telemetry_events';
import { TelemetryClient } from './telemetry_client';
/**
* Service that interacts with the Core's analytics module
* to trigger custom event for the Infra plugin features
*/
export class TelemetryService {
constructor(private analytics: AnalyticsServiceSetup | null = null) {}
public setup({ analytics }: TelemetryServiceSetupParams, context?: Record<string, unknown>) {
this.analytics = analytics;
if (context) {
const context$ = of(context);
analytics.registerContextProvider({
name: 'detection_response',
// RxJS Observable that emits every time the context changes.
context$,
// Similar to the `reportEvent` API, schema defining the structure of the expected output of the context$ observable.
schema: {
prebuiltRulesPackageVersion: {
type: 'keyword',
_meta: { description: 'The version of prebuilt rules', optional: true },
},
},
});
}
telemetryEvents.forEach((eventConfig) =>
analytics.registerEventType<TelemetryEventParams>(eventConfig)
);
}
public start(): TelemetryClientStart {
if (!this.analytics) {
throw new Error(
'The TelemetryService.setup() method has not been invoked, be sure to call it during the plugin setup.'
);
}
return new TelemetryClient(this.analytics);
}
}

View file

@ -0,0 +1,63 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { RootSchema } from '@kbn/analytics-client';
import type { AnalyticsServiceSetup } from '@kbn/core/public';
export interface TelemetryServiceSetupParams {
analytics: AnalyticsServiceSetup;
}
export enum TelemetryEventTypes {
AlertsGroupingChanged = 'Alerts Grouping Changed',
AlertsGroupingToggled = 'Alerts Grouping Toggled',
AlertsGroupingTakeAction = 'Alerts Grouping Take Action',
}
export interface ReportAlertsGroupingChangedParams {
tableId: string;
groupByField: string;
}
export interface ReportAlertsGroupingToggledParams {
isOpen: boolean;
tableId: string;
groupNumber: number;
groupName?: string | undefined;
}
export interface ReportAlertsTakeActionParams {
tableId: string;
groupNumber: number;
status: 'open' | 'closed' | 'acknowledged';
groupByField: string;
}
export type TelemetryEventParams =
| ReportAlertsGroupingChangedParams
| ReportAlertsGroupingToggledParams
| ReportAlertsTakeActionParams;
export interface TelemetryClientStart {
reportAlertsGroupingChanged(params: ReportAlertsGroupingChangedParams): void;
reportAlertsGroupingToggled(params: ReportAlertsGroupingToggledParams): void;
reportAlertsGroupingTakeAction(params: ReportAlertsTakeActionParams): void;
}
export type TelemetryEvent =
| {
eventType: TelemetryEventTypes.AlertsGroupingToggled;
schema: RootSchema<ReportAlertsGroupingToggledParams>;
}
| {
eventType: TelemetryEventTypes.AlertsGroupingChanged;
schema: RootSchema<ReportAlertsGroupingChangedParams>;
}
| {
eventType: TelemetryEventTypes.AlertsGroupingTakeAction;
schema: RootSchema<ReportAlertsTakeActionParams>;
};

View file

@ -45,6 +45,7 @@ import {
useGroupTakeActionsItems,
} from './grouping_settings';
import { updateGroupSelector, updateSelectedGroup } from '../../../common/store/grouping/actions';
import { track } from '../../../common/lib/telemetry';
const ALERTS_GROUPING_ID = 'alerts-grouping';
@ -82,16 +83,19 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
renderChildComponent,
}) => {
const dispatch = useDispatch();
const { browserFields, indexPattern, selectedPatterns } = useSourcererDataView(
SourcererScopeName.detections
);
const kibana = useKibana();
const {
services: { uiSettings, telemetry },
} = useKibana();
const getGlobalQuery = useCallback(
(customFilters: Filter[]) => {
if (browserFields != null && indexPattern != null) {
return combineQueries({
config: getEsQueryConfig(kibana.services.uiSettings),
config: getEsQueryConfig(uiSettings),
dataProviders: [],
indexPattern,
browserFields,
@ -107,13 +111,22 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
}
return null;
},
[browserFields, defaultFilters, globalFilters, globalQuery, indexPattern, kibana, to, from]
[browserFields, indexPattern, uiSettings, defaultFilters, globalFilters, from, to, globalQuery]
);
const onGroupChangeCallback = useCallback(
(param) => {
telemetry.reportAlertsGroupingChanged(param);
},
[telemetry]
);
const { groupSelector, getGrouping, selectedGroup, pagination } = useGrouping({
defaultGroupingOptions: getDefaultGroupingOptions(tableId),
groupingId: tableId,
fields: indexPattern.fields,
onGroupChangeCallback,
tracker: track,
});
const resetPagination = pagination.reset;
@ -221,9 +234,14 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
});
const getTakeActionItems = useCallback(
(groupFilters: Filter[]) =>
takeActionItems(getGlobalQuery([...(defaultFilters ?? []), ...groupFilters])?.filterQuery),
[defaultFilters, getGlobalQuery, takeActionItems]
(groupFilters: Filter[], groupNumber: number) =>
takeActionItems({
query: getGlobalQuery([...(defaultFilters ?? []), ...groupFilters])?.filterQuery,
tableId,
groupNumber,
selectedGroup,
}),
[defaultFilters, getGlobalQuery, selectedGroup, tableId, takeActionItems]
);
const groupedAlerts = useMemo(
@ -236,12 +254,17 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
customMetricStats: (fieldBucket: RawBucket<AlertsGroupingAggregation>) =>
getSelectedGroupCustomMetrics(selectedGroup, fieldBucket),
data: alertsGroupsData?.aggregations,
groupingId: tableId,
groupPanelRenderer: (fieldBucket: RawBucket<AlertsGroupingAggregation>) =>
getSelectedGroupButtonContent(selectedGroup, fieldBucket),
inspectButton: inspect,
isLoading: loading || isLoadingGroups,
onToggleCallback: (param) => {
telemetry.reportAlertsGroupingToggled({ ...param, tableId: param.groupingId });
},
renderChildComponent,
takeActionItems: getTakeActionItems,
tracker: track,
unit: defaultUnit,
}),
[
@ -253,6 +276,8 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
loading,
renderChildComponent,
selectedGroup,
tableId,
telemetry,
]
);

View file

@ -25,6 +25,11 @@ describe('useGroupTakeActionsItems', () => {
const wrapperContainer: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
<TestProviders>{children}</TestProviders>
);
const getActionItemsParams = {
tableId: 'mock-id',
groupNumber: 0,
selectedGroup: 'test',
};
it('returns array take actions items available for alerts table if showAlertStatusActions is true', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(
@ -38,7 +43,7 @@ describe('useGroupTakeActionsItems', () => {
}
);
await waitForNextUpdate();
expect(result.current().length).toEqual(3);
expect(result.current(getActionItemsParams).length).toEqual(3);
});
});
@ -55,7 +60,7 @@ describe('useGroupTakeActionsItems', () => {
}
);
await waitForNextUpdate();
expect(result.current().length).toEqual(0);
expect(result.current(getActionItemsParams).length).toEqual(0);
});
});
});

View file

@ -7,6 +7,7 @@
import React, { useMemo, useCallback } from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { Status } from '../../../../../common/detection_engine/schemas/common';
import type { inputsModel } from '../../../../common/store';
import { inputsSelectors } from '../../../../common/store';
@ -27,7 +28,8 @@ import {
import { FILTER_ACKNOWLEDGED, FILTER_CLOSED, FILTER_OPEN } from '../../../../../common/types';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import * as i18n from '../translations';
import { getTelemetryEvent, METRIC_TYPE, track } from '../../../../common/lib/telemetry';
import type { StartServices } from '../../../../types';
export interface TakeActionsProps {
currentStatus?: Status;
indexName: string;
@ -47,6 +49,21 @@ export const useGroupTakeActionsItems = ({
const refetchQuery = useCallback(() => {
globalQueries.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)());
}, [globalQueries]);
const {
services: { telemetry },
} = useKibana<StartServices>();
const reportAlertsGroupingTakeActionClick = useCallback(
(params: {
tableId: string;
groupNumber: number;
status: 'open' | 'closed' | 'acknowledged';
groupByField: string;
}) => {
telemetry.reportAlertsGroupingTakeAction(params);
},
[telemetry]
);
const onUpdateSuccess = useCallback(
(updated: number, conflicts: number, newStatus: AlertWorkflowStatus) => {
@ -113,13 +130,36 @@ export const useGroupTakeActionsItems = ({
);
const onClickUpdate = useCallback(
async (status: AlertWorkflowStatus, query?: string) => {
async ({
groupNumber,
query,
status,
tableId,
selectedGroup,
}: {
groupNumber: number;
query?: string;
status: AlertWorkflowStatus;
tableId: string;
selectedGroup: string;
}) => {
if (query) {
startTransaction({ name: APM_USER_INTERACTIONS.BULK_QUERY_STATUS_UPDATE });
} else {
startTransaction({ name: APM_USER_INTERACTIONS.STATUS_UPDATE });
}
track(
METRIC_TYPE.CLICK,
getTelemetryEvent.groupedAlertsTakeAction({ tableId, groupNumber, status })
);
reportAlertsGroupingTakeActionClick({
tableId,
groupNumber,
status,
groupByField: selectedGroup,
});
try {
const response = await updateAlertStatus({
index: indexName,
@ -133,16 +173,27 @@ export const useGroupTakeActionsItems = ({
}
},
[
startTransaction,
reportAlertsGroupingTakeActionClick,
updateAlertStatus,
indexName,
onAlertStatusUpdateSuccess,
onAlertStatusUpdateFailure,
startTransaction,
]
);
const items = useMemo(() => {
const getActionItems = (query?: string) => {
const getActionItems = ({
query,
tableId,
groupNumber,
selectedGroup,
}: {
query?: string;
tableId: string;
groupNumber: number;
selectedGroup: string;
}) => {
const actionItems: JSX.Element[] = [];
if (showAlertStatusActions) {
if (currentStatus !== FILTER_OPEN) {
@ -150,7 +201,15 @@ export const useGroupTakeActionsItems = ({
<EuiContextMenuItem
key="open"
data-test-subj="open-alert-status"
onClick={() => onClickUpdate(FILTER_OPEN as AlertWorkflowStatus, query)}
onClick={() =>
onClickUpdate({
groupNumber,
query,
selectedGroup,
status: FILTER_OPEN as AlertWorkflowStatus,
tableId,
})
}
>
{BULK_ACTION_OPEN_SELECTED}
</EuiContextMenuItem>
@ -161,7 +220,15 @@ export const useGroupTakeActionsItems = ({
<EuiContextMenuItem
key="acknowledge"
data-test-subj="acknowledged-alert-status"
onClick={() => onClickUpdate(FILTER_ACKNOWLEDGED as AlertWorkflowStatus, query)}
onClick={() =>
onClickUpdate({
groupNumber,
query,
selectedGroup,
status: FILTER_ACKNOWLEDGED as AlertWorkflowStatus,
tableId,
})
}
>
{BULK_ACTION_ACKNOWLEDGED_SELECTED}
</EuiContextMenuItem>
@ -172,7 +239,15 @@ export const useGroupTakeActionsItems = ({
<EuiContextMenuItem
key="close"
data-test-subj="close-alert-status"
onClick={() => onClickUpdate(FILTER_CLOSED as AlertWorkflowStatus, query)}
onClick={() =>
onClickUpdate({
groupNumber,
query,
selectedGroup,
status: FILTER_CLOSED as AlertWorkflowStatus,
tableId,
})
}
>
{BULK_ACTION_CLOSE_SELECTED}
</EuiContextMenuItem>

View file

@ -30,7 +30,7 @@ import type {
StartedSubPlugins,
StartPluginsDependencies,
} from './types';
import { initTelemetry } from './common/lib/telemetry';
import { initTelemetry, TelemetryService } from './common/lib/telemetry';
import { KibanaServices } from './common/lib/kibana/services';
import { SOLUTION_NAME } from './common/translations';
@ -83,6 +83,8 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
*/
readonly prebuiltRulesPackageVersion?: string;
private config: SecuritySolutionUiConfigType;
private telemetry: TelemetryService;
readonly experimentalFeatures: ExperimentalFeatures;
constructor(private readonly initializerContext: PluginInitializerContext) {
@ -91,6 +93,8 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
this.kibanaVersion = initializerContext.env.packageInfo.version;
this.kibanaBranch = initializerContext.env.packageInfo.branch;
this.prebuiltRulesPackageVersion = this.config.prebuiltRulesPackageVersion;
this.telemetry = new TelemetryService();
}
private appUpdater$ = new Subject<AppUpdater>();
@ -120,6 +124,10 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
},
APP_UI_ID
);
const telemetryContext = {
prebuiltRulesPackageVersion: this.prebuiltRulesPackageVersion,
};
this.telemetry.setup({ analytics: core.analytics }, telemetryContext);
if (plugins.home) {
plugins.home.featureCatalogue.registerSolution({
@ -159,6 +167,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
securityLayout: {
getPluginWrapper: () => SecuritySolutionTemplateWrapper,
},
telemetry: this.telemetry.start(),
};
return services;
};

View file

@ -60,7 +60,7 @@ import type { CloudDefend } from './cloud_defend';
import type { ThreatIntelligence } from './threat_intelligence';
import type { SecuritySolutionTemplateWrapper } from './app/home/template_wrapper';
import type { Explore } from './explore';
import type { TelemetryClientStart } from './common/lib/telemetry';
export interface SetupPlugins {
home?: HomePublicPluginSetup;
licensing: LicensingPluginSetup;
@ -119,6 +119,7 @@ export type StartServices = CoreStart &
securityLayout: {
getPluginWrapper: () => typeof SecuritySolutionTemplateWrapper;
};
telemetry: TelemetryClientStart;
};
export interface PluginSetup {

View file

@ -147,6 +147,8 @@
"@kbn/alerts-as-data-utils",
"@kbn/expandable-flyout",
"@kbn/securitysolution-grouping",
"@kbn/core-analytics-server",
"@kbn/analytics-client",
"@kbn/security-solution-side-nav",
]
}