Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ import { render, screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';

import { TestProviders } from '../../../common/mock';
import { useKibana } from '../../../common/lib/kibana';
import { AttacksEventTypes } from '../../../common/lib/telemetry';
import { CALLOUT_TEST_DATA_ID, HIDE_BUTTON_TEST_DATA_ID, MovingAttacksCallout } from '.';
import { useMovingAttacksCallout } from './use_moving_attacks_callout';
import { mockUseMovingAttacksCallout } from './use_moving_attacks_callout.mock';

jest.mock('./use_moving_attacks_callout');
jest.mock('../../../common/lib/kibana');

const useMovingAttacksCalloutMock = useMovingAttacksCallout as jest.Mock;

Expand All @@ -27,16 +30,28 @@ const renderCallout = () => {
};

describe('MovingAttacksCallout', () => {
const reportEventMock = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
reportEventMock.mockClear();

(useKibana as jest.Mock).mockReturnValue({
services: {
telemetry: {
reportEvent: reportEventMock,
},
},
});

useMovingAttacksCalloutMock.mockReturnValue(mockUseMovingAttacksCallout());
});

it('does not render the callout when isMovingAttacksCalloutVisible is false', () => {
useMovingAttacksCalloutMock.mockReturnValue(
mockUseMovingAttacksCallout({ isMovingAttacksCalloutVisible: false })
);
useMovingAttacksCalloutMock.mockReturnValue({
...mockUseMovingAttacksCallout(),
isMovingAttacksCalloutVisible: false,
});

renderCallout();

Expand All @@ -55,22 +70,37 @@ describe('MovingAttacksCallout', () => {
expect(screen.getByTestId(HIDE_BUTTON_TEST_DATA_ID)).toBeInTheDocument();
});

it('calls hideCallout when the callout is dismissed', async () => {
const mockHideCallout = jest.fn();
it('calls hideCallout and reports telemetry when the callout is dismissed', async () => {
const mockHideMovingAttacksCallout = jest.fn();

useMovingAttacksCalloutMock.mockReturnValue(
mockUseMovingAttacksCallout({
hideMovingAttacksCallout: mockHideCallout,
isMovingAttacksCalloutVisible: true,
})
);
useMovingAttacksCalloutMock.mockReturnValue({
...mockUseMovingAttacksCallout(),
hideMovingAttacksCallout: mockHideMovingAttacksCallout,
isMovingAttacksCalloutVisible: true,
});

renderCallout();

await userEvent.click(screen.getByTestId(HIDE_BUTTON_TEST_DATA_ID));
const hideButton = screen.getByTestId(HIDE_BUTTON_TEST_DATA_ID);
await userEvent.click(hideButton);

await waitFor(() => {
expect(mockHideCallout).toHaveBeenCalled();
expect(mockHideMovingAttacksCallout).toHaveBeenCalled();
});

expect(reportEventMock).toHaveBeenCalledWith(AttacksEventTypes.FeaturePromotionCalloutAction, {
action: 'hide',
});
});

it('reports telemetry when "View attacks" button is clicked', async () => {
renderCallout();

const viewAttacksButton = screen.getByTestId('viewAttacksButton');
await userEvent.click(viewAttacksButton);

expect(reportEventMock).toHaveBeenCalledWith(AttacksEventTypes.FeaturePromotionCalloutAction, {
action: 'view_attacks',
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import React, { useCallback } from 'react';
import { css } from '@emotion/react';
import {
EuiButton,
Expand All @@ -15,6 +15,9 @@ import {
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { SecurityPageName } from '@kbn/deeplinks-security';

import { useKibana } from '../../../common/lib/kibana';
import { AttacksEventTypes } from '../../../common/lib/telemetry';
import { SecuritySolutionLinkButton } from '../../../common/components/links';
import { useMovingAttacksCallout } from './use_moving_attacks_callout';
import * as i18n from './translations';
Expand All @@ -27,6 +30,9 @@ export const HIDE_BUTTON_TEST_DATA_ID = 'hide-callout-button' as string;
*/
export const MovingAttacksCallout: React.FC = React.memo(() => {
const { euiTheme } = useEuiTheme();
const {
services: { telemetry },
} = useKibana();

const { isMovingAttacksCalloutVisible, hideMovingAttacksCallout } = useMovingAttacksCallout();

Expand All @@ -39,6 +45,19 @@ export const MovingAttacksCallout: React.FC = React.memo(() => {
margin-left: ${euiTheme.size.s};
`;

const onViewAttacksClick = useCallback(() => {
telemetry.reportEvent(AttacksEventTypes.FeaturePromotionCalloutAction, {
action: 'view_attacks',
});
}, [telemetry]);

const onHideClick = useCallback(() => {
hideMovingAttacksCallout();
telemetry.reportEvent(AttacksEventTypes.FeaturePromotionCalloutAction, {
action: 'hide',
});
}, [hideMovingAttacksCallout, telemetry]);

return isMovingAttacksCalloutVisible ? (
<>
<EuiCallOut
Expand All @@ -64,6 +83,7 @@ export const MovingAttacksCallout: React.FC = React.memo(() => {
size="s"
deepLinkId={SecurityPageName.attacks}
data-test-subj="viewAttacksButton"
onClick={onViewAttacksClick}
>
{i18n.VIEW_ATTACKS_BUTTON}
</SecuritySolutionLinkButton>
Expand All @@ -72,7 +92,7 @@ export const MovingAttacksCallout: React.FC = React.memo(() => {
data-test-subj={HIDE_BUTTON_TEST_DATA_ID}
css={hideButtonSpacing}
size="s"
onClick={hideMovingAttacksCallout}
onClick={onHideClick}
>
{i18n.HIDE_BUTTON}
</EuiButton>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/*
* 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 { AttacksTelemetryEvent } from './types';
import { AttacksEventTypes } from './types';

export const attacksTableSortChangedEvent: AttacksTelemetryEvent = {
eventType: AttacksEventTypes.TableSortChanged,
schema: {
field: {
type: 'keyword',
_meta: { description: 'The field used for sorting', optional: false },
},
direction: {
type: 'keyword',
_meta: { description: 'The sort direction (asc/desc)', optional: false },
},
},
};

export const attacksViewOptionChangedEvent: AttacksTelemetryEvent = {
eventType: AttacksEventTypes.ViewOptionChanged,
schema: {
option: {
type: 'keyword',
_meta: { description: 'The view option toggled', optional: false },
},
enabled: {
type: 'boolean',
_meta: { description: 'Whether the option was enabled', optional: false },
},
},
};

export const attacksKPIViewChangedEvent: AttacksTelemetryEvent = {
eventType: AttacksEventTypes.KPIViewChanged,
schema: {
view: {
type: 'keyword',
_meta: { description: 'The selected KPI view', optional: false },
},
},
};

const actionSourceSchema = {
source: {
type: 'keyword',
_meta: {
description: 'The source of the action',
optional: false,
},
},
} as const;

const scopeSchema = {
scope: {
type: 'keyword',
_meta: {
description: 'Whether the update was applied to attack only or attack and related alerts',
optional: true,
},
},
} as const;

export const attacksActionStatusUpdatedEvent: AttacksTelemetryEvent = {
eventType: AttacksEventTypes.ActionStatusUpdated,
schema: {
...actionSourceSchema,
...scopeSchema,
status: {
type: 'keyword',
_meta: { description: 'The new status applied', optional: false },
},
},
};

export const attacksActionAssigneeUpdatedEvent: AttacksTelemetryEvent = {
eventType: AttacksEventTypes.ActionAssigneeUpdated,
schema: {
...actionSourceSchema,
...scopeSchema,
},
};

export const attacksActionTagsUpdatedEvent: AttacksTelemetryEvent = {
eventType: AttacksEventTypes.ActionTagsUpdated,
schema: {
...actionSourceSchema,
...scopeSchema,
},
};

export const attacksActionAddedToCaseEvent: AttacksTelemetryEvent = {
eventType: AttacksEventTypes.ActionAddedToCase,
schema: {
...actionSourceSchema,
action: {
type: 'keyword',
_meta: {
description: 'The type of case action (add_to_new_case/add_to_existing_case)',
optional: false,
},
},
},
};

export const attacksTimelineInvestigationOpenedEvent: AttacksTelemetryEvent = {
eventType: AttacksEventTypes.TimelineInvestigationOpened,
schema: actionSourceSchema,
};

export const attacksAIAssistantOpenedEvent: AttacksTelemetryEvent = {
eventType: AttacksEventTypes.AIAssistantOpened,
schema: actionSourceSchema,
};

export const attacksDetailsFlyoutOpenedEvent: AttacksTelemetryEvent = {
eventType: AttacksEventTypes.DetailsFlyoutOpened,
schema: {
id: {
type: 'keyword',
_meta: { description: 'The ID of the attack opened', optional: false },
},
source: {
type: 'keyword',
_meta: { description: 'The source where the details flyout was opened', optional: false },
},
},
};

export const attacksExpandedViewTabClickedEvent: AttacksTelemetryEvent = {
eventType: AttacksEventTypes.ExpandedViewTabClicked,
schema: {
tab: {
type: 'keyword',
_meta: { description: 'The tab clicked in expanded view (summary/alerts)', optional: false },
},
},
};

export const attacksScheduleFlyoutOpenedEvent: AttacksTelemetryEvent = {
eventType: AttacksEventTypes.ScheduleFlyoutOpened,
schema: {
source: {
type: 'keyword',
_meta: { description: 'The source of the schedule flyout open', optional: false },
},
},
};

export const attacksFeaturePromotionCalloutActionEvent: AttacksTelemetryEvent = {
eventType: AttacksEventTypes.FeaturePromotionCalloutAction,
schema: {
action: {
type: 'keyword',
_meta: {
description: 'The action taken on the promotion callout (view_attacks/hide)',
optional: false,
},
},
},
};

export const attacksTelemetryEvents = [
attacksTableSortChangedEvent,
attacksViewOptionChangedEvent,
attacksKPIViewChangedEvent,
attacksActionStatusUpdatedEvent,
attacksActionAssigneeUpdatedEvent,
attacksActionTagsUpdatedEvent,
attacksActionAddedToCaseEvent,
attacksTimelineInvestigationOpenedEvent,
attacksAIAssistantOpenedEvent,
attacksDetailsFlyoutOpenedEvent,
attacksExpandedViewTabClickedEvent,
attacksScheduleFlyoutOpenedEvent,
attacksFeaturePromotionCalloutActionEvent,
];
Loading
Loading