From 07751f748ff0d532266dbe673a9fea0f94c87b14 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Thu, 5 Mar 2026 01:35:44 +0100 Subject: [PATCH] [Security Solution][Attacks/Alerts] Telemetry for Attacks and Alerts Alignment (#256117) --- .../moving_attacks_callout/index.test.tsx | 56 ++++-- .../pages/moving_attacks_callout/index.tsx | 24 ++- .../lib/telemetry/events/attacks/index.ts | 182 +++++++++++++++++ .../lib/telemetry/events/attacks/types.ts | 105 ++++++++++ .../lib/telemetry/events/telemetry_events.ts | 2 + .../public/common/lib/telemetry/types.ts | 7 +- .../components/attacks/content.test.tsx | 50 ++++- .../detections/components/attacks/content.tsx | 8 +- .../attacks_list_panel.test.tsx | 15 ++ .../attacks_list_panel/attacks_list_panel.tsx | 11 +- .../kpi_view_select/kpi_view_select.test.tsx | 19 ++ .../kpis/kpi_view_select/kpi_view_select.tsx | 20 +- .../attack_details_container.test.tsx | 19 ++ .../attack_details_container.tsx | 20 +- .../attacks_group_take_action_items.test.tsx | 148 +++++++++++--- .../table/attacks_group_take_action_items.tsx | 8 + .../table/attacks_table_sort_select.test.tsx | 18 ++ .../table/attacks_table_sort_select.tsx | 29 ++- .../attacks_view_options_popover.test.tsx | 25 +++ .../table/attacks_view_options_popover.tsx | 35 +++- .../table/empty_results_prompt.test.tsx | 15 ++ .../attacks/table/empty_results_prompt.tsx | 189 ++++++++++-------- .../attacks/table/table_section.test.tsx | 37 ++++ .../attacks/table/table_section.tsx | 12 +- .../use_apply_attack_assignees.test.tsx | 55 +++++ .../use_apply_attack_assignees.tsx | 16 +- .../use_apply_attack_tags.test.tsx | 55 +++++ .../apply_actions/use_apply_attack_tags.tsx | 24 ++- .../use_apply_attack_workflow_status.test.tsx | 57 ++++++ .../use_apply_attack_workflow_status.tsx | 17 +- .../use_bulk_attack_assignees_items.test.tsx | 50 ++++- .../use_bulk_attack_assignees_items.tsx | 10 +- .../use_bulk_attack_case_items.test.tsx | 73 +++++++ .../use_bulk_attack_case_items.tsx | 26 ++- ...ack_investigate_in_timeline_items.test.tsx | 47 +++++ ...k_attack_investigate_in_timeline_items.tsx | 17 +- .../use_bulk_attack_tags_items.test.tsx | 40 +++- .../use_bulk_attack_tags_items.tsx | 8 +- ...bulk_attack_workflow_status_items.test.tsx | 111 +++++++++- .../use_bulk_attack_workflow_status_items.tsx | 11 +- ...tack_assignees_context_menu_items.test.tsx | 3 + ...se_attack_assignees_context_menu_items.tsx | 2 + ...se_attack_case_context_menu_items.test.tsx | 22 ++ .../use_attack_case_context_menu_items.tsx | 5 +- ...te_in_timeline_context_menu_items.test.tsx | 14 ++ ...stigate_in_timeline_context_menu_items.tsx | 6 +- ...se_attack_tags_context_menu_items.test.tsx | 16 ++ .../use_attack_tags_context_menu_items.tsx | 2 + ...n_ai_assistant_context_menu_items.test.tsx | 50 +++++ ...iew_in_ai_assistant_context_menu_items.tsx | 25 ++- ...orkflow_status_context_menu_items.test.tsx | 3 + ...ack_workflow_status_context_menu_items.tsx | 4 +- .../hooks/attacks/bulk_actions/types.ts | 6 + .../components/assignees.test.tsx | 1 + .../attack_details/components/assignees.tsx | 1 + .../components/status_popover_button.test.tsx | 16 +- .../components/status_popover_button.tsx | 1 + 57 files changed, 1664 insertions(+), 184 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/attacks/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/attacks/types.ts diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/moving_attacks_callout/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/moving_attacks_callout/index.test.tsx index 8ffc82b0364b9..df7c700231aa5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/moving_attacks_callout/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/moving_attacks_callout/index.test.tsx @@ -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; @@ -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(); @@ -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', }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/moving_attacks_callout/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/moving_attacks_callout/index.tsx index 6f7e1bfb868e4..ed80b03f15e2f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/moving_attacks_callout/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/moving_attacks_callout/index.tsx @@ -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, @@ -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'; @@ -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(); @@ -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 ? ( <> { size="s" deepLinkId={SecurityPageName.attacks} data-test-subj="viewAttacksButton" + onClick={onViewAttacksClick} > {i18n.VIEW_ATTACKS_BUTTON} @@ -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} diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/attacks/index.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/attacks/index.ts new file mode 100644 index 0000000000000..428d307bbe639 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/attacks/index.ts @@ -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, +]; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/attacks/types.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/attacks/types.ts new file mode 100644 index 0000000000000..3ec269a7b4392 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/attacks/types.ts @@ -0,0 +1,105 @@ +/* + * 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/core/public'; + +export enum AttacksEventTypes { + TableSortChanged = 'Attacks Table Sort Changed', + ViewOptionChanged = 'Attacks View Option Changed', + KPIViewChanged = 'Attacks KPI View Changed', + ActionStatusUpdated = 'Attacks Action Status Updated', + ActionAssigneeUpdated = 'Attacks Action Assignee Updated', + ActionTagsUpdated = 'Attacks Action Tags Updated', + ActionAddedToCase = 'Attacks Action Added To Case', + TimelineInvestigationOpened = 'Attacks Timeline Investigation Opened', + AIAssistantOpened = 'Attacks AI Assistant Opened', + DetailsFlyoutOpened = 'Attacks Details Flyout Opened', + ExpandedViewTabClicked = 'Attacks Expanded View Tab Clicked', + ScheduleFlyoutOpened = 'Attacks Schedule Flyout Opened', + FeaturePromotionCalloutAction = 'Attacks Feature Promotion Callout Action', +} + +interface AttacksTableSortChangedParams { + field: string; + direction: 'asc' | 'desc'; +} + +interface AttacksViewOptionChangedParams { + option: string; + enabled: boolean; +} + +interface AttacksKPIViewChangedParams { + view: string; +} + +export type AttacksActionTelemetrySource = + | 'attacks_page_group_summary' + | 'attacks_page_group_take_action' + | 'attacks_page_flyout_header' + | 'attacks_page_flyout_take_action'; + +export type AttacksUpdateScope = 'attack_only' | 'attack_and_related_alerts'; + +export interface AttacksActionBaseParams { + source: AttacksActionTelemetrySource; +} + +interface AttacksScheduleFlyoutOpenedParams { + source: 'attacks_page_header' | 'attacks_page_empty_state'; +} + +interface AttacksActionStatusUpdatedParams extends AttacksActionBaseParams { + status: string; + scope?: AttacksUpdateScope; +} + +interface AttacksActionAssigneeUpdatedParams extends AttacksActionBaseParams { + scope?: AttacksUpdateScope; +} + +interface AttacksActionTagsUpdatedParams extends AttacksActionBaseParams { + scope?: AttacksUpdateScope; +} + +interface AttacksActionAddedToCaseParams extends AttacksActionBaseParams { + action: 'add_to_new_case' | 'add_to_existing_case'; +} + +interface AttacksDetailsFlyoutOpenedParams { + id: string; + source: 'attacks_page_table' | 'attacks_page_summary_kpi'; +} + +interface AttacksExpandedViewTabClickedParams { + tab: 'summary' | 'alerts'; +} + +interface AttacksFeaturePromotionCalloutActionParams { + action: 'view_attacks' | 'hide'; +} + +export interface AttacksTelemetryEventsMap { + [AttacksEventTypes.TableSortChanged]: AttacksTableSortChangedParams; + [AttacksEventTypes.ViewOptionChanged]: AttacksViewOptionChangedParams; + [AttacksEventTypes.KPIViewChanged]: AttacksKPIViewChangedParams; + [AttacksEventTypes.ActionStatusUpdated]: AttacksActionStatusUpdatedParams; + [AttacksEventTypes.ActionAssigneeUpdated]: AttacksActionAssigneeUpdatedParams; + [AttacksEventTypes.ActionTagsUpdated]: AttacksActionTagsUpdatedParams; + [AttacksEventTypes.ActionAddedToCase]: AttacksActionAddedToCaseParams; + [AttacksEventTypes.TimelineInvestigationOpened]: AttacksActionBaseParams; + [AttacksEventTypes.AIAssistantOpened]: AttacksActionBaseParams; + [AttacksEventTypes.DetailsFlyoutOpened]: AttacksDetailsFlyoutOpenedParams; + [AttacksEventTypes.ExpandedViewTabClicked]: AttacksExpandedViewTabClickedParams; + [AttacksEventTypes.ScheduleFlyoutOpened]: AttacksScheduleFlyoutOpenedParams; + [AttacksEventTypes.FeaturePromotionCalloutAction]: AttacksFeaturePromotionCalloutActionParams; +} + +export interface AttacksTelemetryEvent { + eventType: AttacksEventTypes; + schema: RootSchema; +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts index d35ec25e259c2..f87f8b6370216 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts @@ -19,8 +19,10 @@ import { previewRuleTelemetryEvents } from './preview_rule'; import { siemMigrationsTelemetryEvents } from './siem_migrations'; import { ruleUpgradeTelemetryEvents } from './rule_upgrade'; import { aiValueReportTelemetryEvents } from './ai_value_report'; +import { attacksTelemetryEvents } from './attacks'; export const telemetryEvents = [ + ...attacksTelemetryEvents, ...alertsTelemetryEvents, ...previewRuleTelemetryEvents, ...entityTelemetryEvents, diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/types.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/types.ts index 8c39bba9f7655..d89857b326e8b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/types.ts @@ -59,12 +59,14 @@ import type { AIValueReportEventTypes, AIValueReportTelemetryEventsMap, } from './events/ai_value_report/types'; +import type { AttacksEventTypes, AttacksTelemetryEventsMap } from './events/attacks/types'; import type { TrialCompanionEventTypes, TrialCompanionTelemetryEventsMap, } from './events/trial_companion/types'; export * from './events/app/types'; +export * from './events/attacks/types'; export * from './events/alerts_grouping/types'; export * from './events/data_quality/types'; export * from './events/onboarding/types'; @@ -115,6 +117,8 @@ export type TelemetryEventTypeData = T extends Al ? TrialCompanionTelemetryEventsMap[T] : T extends AgentBuilderEventTypes ? AgentBuilderTelemetryEventsMap[T] + : T extends AttacksEventTypes + ? AttacksTelemetryEventsMap[T] : never; export type TelemetryEventTypes = @@ -134,4 +138,5 @@ export type TelemetryEventTypes = | RuleUpgradeEventTypes | AIValueReportEventTypes | TrialCompanionEventTypes - | AgentBuilderEventTypes; + | AgentBuilderEventTypes + | AttacksEventTypes; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/content.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/content.test.tsx index 2d5a75b55f075..011ba36478db3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/content.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/content.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import type { DataView } from '@kbn/data-views-plugin/common'; import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub'; @@ -14,16 +14,52 @@ import { TestProviders } from '../../../common/mock'; import { AttacksPageContent, SECURITY_SOLUTION_PAGE_WRAPPER_TEST_ID } from './content'; import { KPIS_SECTION } from './kpis/kpis_section'; import { TABLE_SECTION_TEST_ID } from './table/table_section'; -import { FILTER_BY_ASSIGNEES_BUTTON } from '../../../common/components/filter_by_assignees_popover/test_ids'; +import { useKibana } from '../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../common/lib/telemetry'; + +jest.mock('../../../common/lib/kibana'); jest.mock('./kpis/kpis_section', () => ({ KPIsSection: () =>
, KPIS_SECTION: 'attacks-kpis-section', })); +jest.mock('./search_bar/search_bar_section', () => ({ + SearchBarSection: () =>
, +})); + +jest.mock( + '../../../common/components/filter_by_assignees_popover/filter_by_assignees_popover', + () => ({ + FilterByAssigneesPopover: () =>
, + }) +); + +jest.mock('./table/table_section', () => ({ + TableSection: () =>
, + TABLE_SECTION_TEST_ID: 'attacks-page-table-section', +})); + +jest.mock('./schedule_flyout', () => ({ + SchedulesFlyout: () =>
, +})); + const dataView: DataView = createStubDataView({ spec: {} }); describe('AttacksPageContent', () => { + const reportEvent = jest.fn(); + + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + settings: {}, + telemetry: { + reportEvent, + }, + }, + }); + }); + it('should render correctly', async () => { render( @@ -38,7 +74,7 @@ describe('AttacksPageContent', () => { }); }); - it('should render `Schedule` button', async () => { + it('should render `Schedule` button and report telemetry when clicked', async () => { render( @@ -48,6 +84,12 @@ describe('AttacksPageContent', () => { await waitFor(() => { expect(screen.getByTestId('schedule')).toBeInTheDocument(); }); + + fireEvent.click(screen.getByTestId('schedule')); + + expect(reportEvent).toHaveBeenCalledWith(AttacksEventTypes.ScheduleFlyoutOpened, { + source: 'attacks_page_header', + }); }); it('should render `Connector` filter', async () => { @@ -70,7 +112,7 @@ describe('AttacksPageContent', () => { ); await waitFor(() => { - expect(screen.getByTestId(FILTER_BY_ASSIGNEES_BUTTON)).toBeInTheDocument(); + expect(screen.getByTestId('mock-filter-by-assignees-popover')).toBeInTheDocument(); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/content.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/content.tsx index 844e714f7c894..5606f289a127e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/content.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/content.tsx @@ -24,6 +24,7 @@ import type { FilterGroupHandler } from '@kbn/alerts-ui-shared'; import { dataTableSelectors, tableDefaults, TableId } from '@kbn/securitysolution-data-table'; import { useGlobalTime } from '../../../common/containers/use_global_time'; import { useKibana } from '../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../common/lib/telemetry'; import { useFindAttackDiscoveries } from '../../../attack_discovery/pages/use_find_attack_discoveries'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { Schedule } from '../../../attack_discovery/pages/header/schedule'; @@ -71,7 +72,7 @@ export const AttacksPageContent = React.memo(({ dataView }: AttacksPageContentPr const { globalFullScreen } = useGlobalFullScreen(); const [selectedConnectorNames, setSelectedConnectorNames] = useState([]); const { - services: { settings }, + services: { settings, telemetry }, } = useKibana(); const { http, inferenceEnabled } = useAssistantContext(); @@ -93,7 +94,10 @@ export const AttacksPageContent = React.memo(({ dataView }: AttacksPageContentPr const [showSchedulesFlyout, setShowSchedulesFlyout] = useState(false); const openSchedulesFlyout = useCallback(() => { setShowSchedulesFlyout(true); - }, []); + telemetry.reportEvent(AttacksEventTypes.ScheduleFlyoutOpened, { + source: 'attacks_page_header', + }); + }, [telemetry]); const onCloseSchedulesFlyout = useCallback(() => setShowSchedulesFlyout(false), []); const [assignees, setAssignees] = useState([]); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/kpis/attacks_list_panel/attacks_list_panel.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/kpis/attacks_list_panel/attacks_list_panel.test.tsx index 5976cdf5cdd4a..7c582c1a1b59b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/kpis/attacks_list_panel/attacks_list_panel.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/kpis/attacks_list_panel/attacks_list_panel.test.tsx @@ -12,7 +12,10 @@ import type { DataView } from '@kbn/data-views-plugin/common'; import { AttacksListPanel } from './attacks_list_panel'; import { useAttacksListData } from './use_attacks_list_data'; import { AttackDetailsRightPanelKey } from '../../../../../flyout/attack_details/constants/panel_keys'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; +jest.mock('../../../../../common/lib/kibana'); jest.mock('./use_attacks_list_data'); jest.mock('@kbn/expandable-flyout'); jest.mock('../../../../../entity_analytics/components/severity/severity_bar', () => ({ @@ -26,11 +29,19 @@ describe('AttacksListPanel', () => { } as unknown as DataView; const mockOpenFlyout = jest.fn(); + const reportEvent = jest.fn(); beforeEach(() => { (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openFlyout: mockOpenFlyout, }); + (useKibana as jest.Mock).mockReturnValue({ + services: { + telemetry: { + reportEvent, + }, + }, + }); }); afterEach(() => { @@ -115,6 +126,10 @@ describe('AttacksListPanel', () => { }, }, }); + expect(reportEvent).toHaveBeenCalledWith(AttacksEventTypes.DetailsFlyoutOpened, { + id: 'attack-1', + source: 'attacks_page_summary_kpi', + }); }); it('handles pagination changes', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/kpis/attacks_list_panel/attacks_list_panel.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/kpis/attacks_list_panel/attacks_list_panel.tsx index b5c297ca16312..c011c001a5ec6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/kpis/attacks_list_panel/attacks_list_panel.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/kpis/attacks_list_panel/attacks_list_panel.tsx @@ -26,6 +26,8 @@ import { AttackDetailsRightPanelKey } from '../../../../../flyout/attack_details import { SeverityBar } from '../../../../../entity_analytics/components/severity/severity_bar'; import { useAttacksListData } from './use_attacks_list_data'; import type { AttacksListItem } from './types'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; const PAGE_SIZE = 10; const TABLE_WIDTH = 385; @@ -61,6 +63,9 @@ export interface AttacksListPanelProps { export const AttacksListPanel = React.memo( ({ filters, query, dataView }) => { const { openFlyout } = useExpandableFlyoutApi(); + const { + services: { telemetry }, + } = useKibana(); const { items, isLoading, pageIndex, setPageIndex, pageSize, setPageSize, total } = useAttacksListData({ @@ -92,6 +97,10 @@ export const AttacksListPanel = React.memo( }, }, }); + telemetry.reportEvent(AttacksEventTypes.DetailsFlyoutOpened, { + id: item.id, + source: 'attacks_page_summary_kpi', + }); }} title={name} > @@ -121,7 +130,7 @@ export const AttacksListPanel = React.memo( ), }, ], - [dataView, openFlyout] + [dataView, openFlyout, telemetry] ); const pagination = { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/kpis/kpi_view_select/kpi_view_select.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/kpis/kpi_view_select/kpi_view_select.test.tsx index 2f2676e7c98c7..74f0e5e4c7c38 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/kpis/kpi_view_select/kpi_view_select.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/kpis/kpi_view_select/kpi_view_select.test.tsx @@ -11,11 +11,24 @@ import { TestProviders } from '../../../../../common/mock'; import { KpiViewSelect } from './kpi_view_select'; import { KpiViewSelection } from './helpers'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; + +jest.mock('../../../../../common/lib/kibana'); + describe('', () => { const mockSetKpiViewSelection = jest.fn(); + const reportEventMock = jest.fn(); beforeEach(() => { jest.clearAllMocks(); + (useKibana as jest.Mock).mockReturnValue({ + services: { + telemetry: { + reportEvent: reportEventMock, + }, + }, + }); }); it('renders the view selector with all tabs', () => { @@ -62,6 +75,9 @@ describe('', () => { fireEvent.click(screen.getByTestId('kpi-view-select-count')); expect(mockSetKpiViewSelection).toHaveBeenCalledWith(KpiViewSelection.Count); + expect(reportEventMock).toHaveBeenCalledWith(AttacksEventTypes.KPIViewChanged, { + view: KpiViewSelection.Count, + }); }); it('calls setKpiViewSelection with treemap when treemap tab is clicked', () => { @@ -77,5 +93,8 @@ describe('', () => { fireEvent.click(screen.getByTestId('kpi-view-select-treemap')); expect(mockSetKpiViewSelection).toHaveBeenCalledWith(KpiViewSelection.Treemap); + expect(reportEventMock).toHaveBeenCalledWith(AttacksEventTypes.KPIViewChanged, { + view: KpiViewSelection.Treemap, + }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/kpis/kpi_view_select/kpi_view_select.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/kpis/kpi_view_select/kpi_view_select.tsx index 2074a68807761..c20d23e8f5566 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/kpis/kpi_view_select/kpi_view_select.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/kpis/kpi_view_select/kpi_view_select.tsx @@ -6,7 +6,10 @@ */ import { EuiButtonGroup } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import React, { useMemo, useCallback } from 'react'; + +import { useKibana } from '../../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; import { KpiViewSelection, getOptionProperties } from './helpers'; import * as i18n from './translations'; @@ -24,17 +27,30 @@ export interface KpiViewSelectProps { export const KpiViewSelect: React.FC = React.memo( ({ kpiViewSelection, setKpiViewSelection }) => { + const { + services: { telemetry }, + } = useKibana(); + const options = useMemo( () => KPI_VIEW_OPTIONS.map((option) => getOptionProperties(option)), [] ); + const onKpiViewChange = useCallback( + (id: string) => { + const view = id as KpiViewSelection; + setKpiViewSelection(view); + telemetry.reportEvent(AttacksEventTypes.KPIViewChanged, { view }); + }, + [setKpiViewSelection, telemetry] + ); + return ( setKpiViewSelection(id as KpiViewSelection)} + onChange={onKpiViewChange} buttonSize="compressed" color="primary" data-test-subj="kpi-view-select-tabs" diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attack_details/attack_details_container.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attack_details/attack_details_container.test.tsx index f1f101c43cb3e..dd53259eaa76d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attack_details/attack_details_container.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attack_details/attack_details_container.test.tsx @@ -38,6 +38,11 @@ jest.mock('../../../../../common/components/local_storage', () => ({ useLocalStorage: jest.fn(), })); +import { useKibana } from '../../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; + +jest.mock('../../../../../common/lib/kibana'); + describe('AttackDetailsContainer', () => { const mockAttack = getMockAttackDiscoveryAlerts()[0]; const defaultProps = { @@ -49,6 +54,7 @@ describe('AttackDetailsContainer', () => { filteredAlertsCount: 5, }; const mockSetSelectedTabId = jest.fn(); + const reportEventMock = jest.fn(); const renderContainer = (props = {}) => render( @@ -60,6 +66,13 @@ describe('AttackDetailsContainer', () => { beforeEach(() => { jest.clearAllMocks(); (useLocalStorage as jest.Mock).mockReturnValue([ATTACK_SUMMARY_TAB, mockSetSelectedTabId]); + (useKibana as jest.Mock).mockReturnValue({ + services: { + telemetry: { + reportEvent: reportEventMock, + }, + }, + }); }); describe('tab rendering', () => { @@ -119,6 +132,9 @@ describe('AttackDetailsContainer', () => { fireEvent.click(screen.getByText('Alerts')); expect(mockSetSelectedTabId).toHaveBeenCalledWith(ALERTS_TAB); + expect(reportEventMock).toHaveBeenCalledWith(AttacksEventTypes.ExpandedViewTabClicked, { + tab: 'alerts', + }); }); it('updates stored value to summary tab when tab is clicked', () => { @@ -128,6 +144,9 @@ describe('AttackDetailsContainer', () => { fireEvent.click(screen.getByText('Attack summary')); expect(mockSetSelectedTabId).toHaveBeenCalledWith(ATTACK_SUMMARY_TAB); + expect(reportEventMock).toHaveBeenCalledWith(AttacksEventTypes.ExpandedViewTabClicked, { + tab: 'summary', + }); }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attack_details/attack_details_container.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attack_details/attack_details_container.tsx index 89462226a513f..b0732d13f750b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attack_details/attack_details_container.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attack_details/attack_details_container.tsx @@ -5,11 +5,13 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import type { AttackDiscoveryAlert } from '@kbn/elastic-assistant-common'; import type { Filter } from '@kbn/es-query'; import { EuiSpacer, EuiTabs, EuiTab, EuiNotificationBadge } from '@elastic/eui'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; import { useLocalStorage } from '../../../../../common/components/local_storage'; import { getSettingKey } from '../../../../../common/components/local_storage/helpers'; import { @@ -67,6 +69,10 @@ export const AttackDetailsContainer = React.memo( showAnonymized, filteredAlertsCount, }) => { + const { + services: { telemetry }, + } = useKibana(); + const [selectedTabId, setSelectedTabId] = useLocalStorage({ defaultValue: ATTACK_SUMMARY_TAB, key: getSettingKey({ @@ -115,6 +121,16 @@ export const AttackDetailsContainer = React.memo( return tabs.find((obj) => obj.id === selectedTabId)?.content; }, [selectedTabId, tabs]); + const onTabClick = useCallback( + (tabId: string) => { + setSelectedTabId(tabId); + telemetry.reportEvent(AttacksEventTypes.ExpandedViewTabClicked, { + tab: tabId === ATTACK_SUMMARY_TAB ? 'summary' : 'alerts', + }); + }, + [setSelectedTabId, telemetry] + ); + return ( <> @@ -122,7 +138,7 @@ export const AttackDetailsContainer = React.memo( setSelectedTabId(tab.id)} + onClick={() => onTabClick(tab.id)} append={tab.append} > {tab.name} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.test.tsx index 526f52f3ca4d6..0dfd65b5eda26 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.test.tsx @@ -10,40 +10,56 @@ import { render } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; import { AttacksGroupTakeActionItems } from './attacks_group_take_action_items'; import { getMockAttackDiscoveryAlerts } from '../../../../attack_discovery/pages/mock/mock_attack_discovery_alerts'; -import { useViewInAiAssistant } from '../../../../attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant'; -import { useAttacksPrivileges } from '../../../hooks/attacks/bulk_actions/use_attacks_privileges'; import { useAttackViewInAiAssistantContextMenuItems } from '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items'; +import { useAttackWorkflowStatusContextMenuItems } from '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_workflow_status_context_menu_items'; +import { useAttackAssigneesContextMenuItems } from '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_assignees_context_menu_items'; +import { useAttackTagsContextMenuItems } from '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_tags_context_menu_items'; +import { useAttackInvestigateInTimelineContextMenuItems } from '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items'; +import { useAttackCaseContextMenuItems } from '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items'; import type { AttackDiscoveryAlert } from '@kbn/elastic-assistant-common'; jest.mock( - '../../../../attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant' + '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items' ); -jest.mock('../../../hooks/attacks/bulk_actions/use_attacks_privileges'); jest.mock( - '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items' + '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_workflow_status_context_menu_items' ); -jest.mock('../../../../common/components/user_privileges', () => ({ - useUserPrivileges: () => ({ - timelinePrivileges: { read: true }, - detectionEnginePrivileges: { loading: false }, - rulesPrivileges: { rules: { read: true, edit: true } }, - }), -})); -jest.mock('../../../../common/hooks/use_license', () => ({ - useLicense: () => ({ - isPlatinumPlus: () => true, - }), -})); -const mockUseAttacksPrivileges = useAttacksPrivileges as jest.MockedFunction< - typeof useAttacksPrivileges ->; -const mockUseViewInAiAssistant = useViewInAiAssistant as jest.MockedFunction< - typeof useViewInAiAssistant ->; +jest.mock( + '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_assignees_context_menu_items' +); +jest.mock( + '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_tags_context_menu_items' +); +jest.mock( + '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items' +); +jest.mock( + '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items' +); + const mockUseAttackViewInAiAssistantContextMenuItems = useAttackViewInAiAssistantContextMenuItems as jest.MockedFunction< typeof useAttackViewInAiAssistantContextMenuItems >; +const mockUseAttackWorkflowStatusContextMenuItems = + useAttackWorkflowStatusContextMenuItems as jest.MockedFunction< + typeof useAttackWorkflowStatusContextMenuItems + >; +const mockUseAttackAssigneesContextMenuItems = + useAttackAssigneesContextMenuItems as jest.MockedFunction< + typeof useAttackAssigneesContextMenuItems + >; +const mockUseAttackTagsContextMenuItems = useAttackTagsContextMenuItems as jest.MockedFunction< + typeof useAttackTagsContextMenuItems +>; +const mockUseAttackInvestigateInTimelineContextMenuItems = + useAttackInvestigateInTimelineContextMenuItems as jest.MockedFunction< + typeof useAttackInvestigateInTimelineContextMenuItems + >; +const mockUseAttackCaseContextMenuItems = useAttackCaseContextMenuItems as jest.MockedFunction< + typeof useAttackCaseContextMenuItems +>; + const mockAttack = getMockAttackDiscoveryAlerts()[0]; function renderAttack(attack: AttackDiscoveryAlert) { @@ -57,16 +73,8 @@ function renderAttack(attack: AttackDiscoveryAlert) { describe('AttacksGroupTakeActionItems', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseAttacksPrivileges.mockReturnValue({ - hasIndexWrite: true, - hasAttackIndexWrite: true, - loading: false, - }); - mockUseViewInAiAssistant.mockReturnValue({ - showAssistantOverlay: jest.fn(), - disabled: false, - promptContextId: 'prompt-context-id', - }); + + // Default mock returns for context menu hooks mockUseAttackViewInAiAssistantContextMenuItems.mockReturnValue({ items: [ { @@ -76,6 +84,59 @@ describe('AttacksGroupTakeActionItems', () => { }, ], }); + mockUseAttackWorkflowStatusContextMenuItems.mockReturnValue({ + items: [ + { name: 'Mark as acknowledged', key: 'markAsAcknowledged' }, + { name: 'Mark as closed', key: 'markAsClosed' }, + { name: 'Mark as open', key: 'markAsOpen' }, + ], + panels: [], + }); + mockUseAttackAssigneesContextMenuItems.mockReturnValue({ + items: [ + { name: 'Assign alert', key: 'assignAlert' }, + { name: 'Unassign alert', key: 'unassignAlert' }, + ], + panels: [], + }); + mockUseAttackTagsContextMenuItems.mockReturnValue({ + items: [{ name: 'Apply alert tags', key: 'applyAlertTags' }], + panels: [], + }); + mockUseAttackInvestigateInTimelineContextMenuItems.mockReturnValue({ + items: [{ name: 'Investigate in timeline', key: 'investigateInTimeline' }], + panels: [], + }); + mockUseAttackCaseContextMenuItems.mockReturnValue({ + items: [], + panels: [], + }); + }); + + describe('telemetry', () => { + it('passes telemetrySource to all hooks', () => { + renderAttack(mockAttack); + const expectedTelemetrySource = 'attacks_page_group_take_action'; + + expect(mockUseAttackViewInAiAssistantContextMenuItems).toHaveBeenCalledWith( + expect.objectContaining({ telemetrySource: expectedTelemetrySource }) + ); + expect(mockUseAttackWorkflowStatusContextMenuItems).toHaveBeenCalledWith( + expect.objectContaining({ telemetrySource: expectedTelemetrySource }) + ); + expect(mockUseAttackAssigneesContextMenuItems).toHaveBeenCalledWith( + expect.objectContaining({ telemetrySource: expectedTelemetrySource }) + ); + expect(mockUseAttackTagsContextMenuItems).toHaveBeenCalledWith( + expect.objectContaining({ telemetrySource: expectedTelemetrySource }) + ); + expect(mockUseAttackInvestigateInTimelineContextMenuItems).toHaveBeenCalledWith( + expect.objectContaining({ telemetrySource: expectedTelemetrySource }) + ); + expect(mockUseAttackCaseContextMenuItems).toHaveBeenCalledWith( + expect.objectContaining({ telemetrySource: expectedTelemetrySource }) + ); + }); }); describe('workflow items', () => { @@ -91,6 +152,13 @@ describe('AttacksGroupTakeActionItems', () => { expect(await findByText('Mark as closed')).toBeInTheDocument(); }); it('should NOT render the `open` action item', async () => { + mockUseAttackWorkflowStatusContextMenuItems.mockReturnValue({ + items: [ + { name: 'Mark as acknowledged', key: 'markAsAcknowledged' }, + { name: 'Mark as closed', key: 'markAsClosed' }, + ], + panels: [], + }); const { queryByText } = renderAttack(openAttack); expect(queryByText('Mark as open')).not.toBeInTheDocument(); }); @@ -103,6 +171,13 @@ describe('AttacksGroupTakeActionItems', () => { expect(await findByText('Mark as acknowledged')).toBeInTheDocument(); }); it('should NOT render the `close` action item', async () => { + mockUseAttackWorkflowStatusContextMenuItems.mockReturnValue({ + items: [ + { name: 'Mark as acknowledged', key: 'markAsAcknowledged' }, + { name: 'Mark as open', key: 'markAsOpen' }, + ], + panels: [], + }); const { queryByText } = renderAttack(openAttack); expect(queryByText('Mark as closed')).not.toBeInTheDocument(); }); @@ -115,6 +190,13 @@ describe('AttacksGroupTakeActionItems', () => { const openAttack = { ...mockAttack, alertWorkflowStatus: 'acknowledged' }; it('should NOT render the `acknowledged` action item', async () => { + mockUseAttackWorkflowStatusContextMenuItems.mockReturnValue({ + items: [ + { name: 'Mark as closed', key: 'markAsClosed' }, + { name: 'Mark as open', key: 'markAsOpen' }, + ], + panels: [], + }); const { queryByText } = renderAttack(openAttack); expect(queryByText('Mark as acknowledged')).not.toBeInTheDocument(); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.tsx index 4a7de4abd2ab4..31a2883981d2a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.tsx @@ -60,10 +60,13 @@ export function AttacksGroupTakeActionItems({ refetchQuery(); }, [invalidateAttackDiscoveriesCache, refetchQuery]); + const telemetrySource = 'attacks_page_group_take_action'; + const { items: assignItems, panels: assignPanels } = useAttackAssigneesContextMenuItems({ attacksWithAssignees, onSuccess, closePopover, + telemetrySource, }); const attacksWithWorkflowStatus = useMemo(() => { @@ -76,6 +79,7 @@ export function AttacksGroupTakeActionItems({ attacksWithWorkflowStatus, onSuccess, closePopover, + telemetrySource, }); const attacksWithTags = useMemo(() => { @@ -86,6 +90,7 @@ export function AttacksGroupTakeActionItems({ attacksWithTags, onSuccess, closePopover, + telemetrySource, }); const attacksWithTimelineAlerts = useMemo(() => [{ ...baseAttackProps }], [baseAttackProps]); @@ -93,6 +98,7 @@ export function AttacksGroupTakeActionItems({ const { items: investigateInTimelineItems } = useAttackInvestigateInTimelineContextMenuItems({ attacksWithTimelineAlerts, closePopover, + telemetrySource, }); const attacksWithCase = useMemo( @@ -112,10 +118,12 @@ export function AttacksGroupTakeActionItems({ closePopover, title: attack.title, attacksWithCase, + telemetrySource, }); const { items: viewInAiAssistantItems } = useAttackViewInAiAssistantContextMenuItems({ attack, closePopover, + telemetrySource, }); const defaultPanel: EuiContextMenuPanelDescriptor = useMemo( diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_table_sort_select.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_table_sort_select.test.tsx index 08fbb5d7afc16..422868bc5e76b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_table_sort_select.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_table_sort_select.test.tsx @@ -16,12 +16,26 @@ import { import type { GroupingSort } from '@kbn/grouping/src'; import * as i18n from './translations'; +import { useKibana } from '../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../common/lib/telemetry'; + +jest.mock('../../../../common/lib/kibana'); + describe('AttacksTableSortSelect', () => { const defaultSort: GroupingSort = DEFAULT_ATTACKS_SORT; const onChange = jest.fn(); + const reportEventMock = jest.fn(); beforeEach(() => { onChange.mockClear(); + reportEventMock.mockClear(); + (useKibana as jest.Mock).mockReturnValue({ + services: { + telemetry: { + reportEvent: reportEventMock, + }, + }, + }); }); it('renders correctly with default sort', () => { @@ -53,6 +67,10 @@ describe('AttacksTableSortSelect', () => { fireEvent.click(leastRecentOption); expect(onChange).toHaveBeenCalledWith([{ latestTimestamp: { order: 'asc' } }]); + expect(reportEventMock).toHaveBeenCalledWith(AttacksEventTypes.TableSortChanged, { + field: 'latestTimestamp', + direction: 'asc', + }); }); it('displays correct label for non-default sort', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_table_sort_select.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_table_sort_select.tsx index 3dc1bc3227ffa..7e102e5202b00 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_table_sort_select.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_table_sort_select.tsx @@ -9,6 +9,9 @@ import React, { useState, useMemo, useCallback } from 'react'; import type { EuiSelectableOption } from '@elastic/eui'; import { EuiButtonEmpty, EuiPopover, EuiSelectable, EuiIcon, useEuiTheme } from '@elastic/eui'; import type { GroupingSort } from '@kbn/grouping/src'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../common/lib/telemetry'; import * as i18n from './translations'; export const ATTACKS_TABLE_SORT_SELECT_TEST_ID = 'attacks-table-sort-select'; @@ -28,13 +31,13 @@ const options: SortOption[] = [ key: 'mostRecent', label: i18n.MOST_RECENT, sortValue: DEFAULT_ATTACKS_SORT, - append: , + append: , }, { key: 'leastRecent', label: i18n.LEAST_RECENT, sortValue: [{ latestTimestamp: { order: 'asc' } }], - append: , + append: , }, { key: 'mostAlerts', @@ -43,7 +46,7 @@ const options: SortOption[] = [ // which is a numeric value that can be used for sorting. // https://www.elastic.co/docs/reference/aggregations/pipeline#buckets-path-syntax sortValue: [{ 'attackRelatedAlerts>_count': { order: 'desc' } }], - append: , + append: , }, { key: 'leastAlerts', @@ -52,7 +55,7 @@ const options: SortOption[] = [ // which is a numeric value that can be used for sorting. // https://www.elastic.co/docs/reference/aggregations/pipeline#buckets-path-syntax sortValue: [{ 'attackRelatedAlerts>_count': { order: 'asc' } }], - append: , + append: , }, ]; @@ -65,6 +68,9 @@ export const AttacksTableSortSelect = React.memo( ({ sort, onChange }: AttacksTableSortSelectProps) => { const { euiTheme } = useEuiTheme(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const { + services: { telemetry }, + } = useKibana(); const selectedOption = useMemo(() => { // Simple comparison based on structure @@ -93,11 +99,22 @@ export const AttacksTableSortSelect = React.memo( (newOptions: SortOption[]) => { const selected = newOptions.find((o) => o.checked === 'on'); if (selected) { - onChange(selected.sortValue); + const sortValue = selected.sortValue; + onChange(sortValue); closePopover(); + + if (sortValue && sortValue.length > 0) { + const field = Object.keys(sortValue[0])[0]; + const direction = sortValue[0][field]?.order as 'asc' | 'desc'; + + telemetry.reportEvent(AttacksEventTypes.TableSortChanged, { + field, + direction, + }); + } } }, - [closePopover, onChange] + [closePopover, onChange, telemetry] ); const button = ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_view_options_popover.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_view_options_popover.test.tsx index 411f8d4b4236d..73db2e46bc605 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_view_options_popover.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_view_options_popover.test.tsx @@ -9,6 +9,10 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; import { AttacksViewOptionsPopover } from './attacks_view_options_popover'; import { TABLE_SECTION_TEST_ID } from './table_section'; +import { useKibana } from '../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../common/lib/telemetry'; + +jest.mock('../../../../common/lib/kibana'); describe('AttacksViewOptionsPopover', () => { const defaultProps = { @@ -18,6 +22,19 @@ describe('AttacksViewOptionsPopover', () => { onToggleShowAttacksOnly: jest.fn(), }; + const reportEvent = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useKibana as jest.Mock).mockReturnValue({ + services: { + telemetry: { + reportEvent, + }, + }, + }); + }); + it('renders the view options button', () => { const { getByTestId } = render(); expect(getByTestId(`${TABLE_SECTION_TEST_ID}-view-options-button`)).toBeInTheDocument(); @@ -45,6 +62,10 @@ describe('AttacksViewOptionsPopover', () => { fireEvent.click(getByTestId(`${TABLE_SECTION_TEST_ID}-show-anonymized-switch`)); expect(defaultProps.onToggleShowAnonymized).toHaveBeenCalled(); + expect(reportEvent).toHaveBeenCalledWith(AttacksEventTypes.ViewOptionChanged, { + option: 'showAnonymized', + enabled: true, + }); }); it('calls onToggleShowAttacksOnly when the attacks only switch is toggled', async () => { @@ -58,6 +79,10 @@ describe('AttacksViewOptionsPopover', () => { fireEvent.click(getByTestId(`${TABLE_SECTION_TEST_ID}-show-attacks-only-switch`)); expect(defaultProps.onToggleShowAttacksOnly).toHaveBeenCalled(); + expect(reportEvent).toHaveBeenCalledWith(AttacksEventTypes.ViewOptionChanged, { + option: 'showAttacksOnly', + enabled: false, + }); }); it('renders switches with correct checked state', async () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_view_options_popover.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_view_options_popover.tsx index 9c19cd3604595..16e4fdfde53c3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_view_options_popover.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_view_options_popover.tsx @@ -5,7 +5,8 @@ * 2.0. */ -import React, { useState, useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; +import type { EuiSwitchEvent } from '@elastic/eui'; import { EuiPopover, EuiButtonIcon, @@ -14,6 +15,9 @@ import { EuiSpacer, useEuiTheme, } from '@elastic/eui'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../common/lib/telemetry'; import * as i18n from './translations'; import { TABLE_SECTION_TEST_ID } from './table_section'; @@ -32,6 +36,9 @@ export const AttacksViewOptionsPopover: React.FC }) => { const { euiTheme } = useEuiTheme(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const { + services: { telemetry }, + } = useKibana(); const onButtonClick = useCallback(() => { setIsPopoverOpen((isOpen) => !isOpen); @@ -41,6 +48,28 @@ export const AttacksViewOptionsPopover: React.FC setIsPopoverOpen(false); }, []); + const handleToggleShowAnonymized = useCallback( + (e: EuiSwitchEvent) => { + onToggleShowAnonymized(); + telemetry.reportEvent(AttacksEventTypes.ViewOptionChanged, { + option: 'showAnonymized', + enabled: e.target.checked, + }); + }, + [onToggleShowAnonymized, telemetry] + ); + + const handleToggleShowAttacksOnly = useCallback( + (e: EuiSwitchEvent) => { + onToggleShowAttacksOnly(); + telemetry.reportEvent(AttacksEventTypes.ViewOptionChanged, { + option: 'showAttacksOnly', + enabled: e.target.checked, + }); + }, + [onToggleShowAttacksOnly, telemetry] + ); + const button = ( @@ -74,7 +103,7 @@ export const AttacksViewOptionsPopover: React.FC diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/empty_results_prompt.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/empty_results_prompt.test.tsx index 43cfc2b0eebe3..2d467d15aa558 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/empty_results_prompt.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/empty_results_prompt.test.tsx @@ -20,6 +20,10 @@ import { EMPTY_RESULTS_FOOTER_MESSAGE_ID, } from './empty_results_prompt'; import * as i18n from './translations'; +import { useKibana } from '../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../common/lib/telemetry'; + +jest.mock('../../../../common/lib/kibana'); const renderWithIntl = (component: React.ReactElement) => { return render({component}); @@ -27,9 +31,17 @@ const renderWithIntl = (component: React.ReactElement) => { describe('EmptyResultsPrompt', () => { const openSchedulesFlyout = jest.fn(); + const reportEvent = jest.fn(); beforeEach(() => { jest.clearAllMocks(); + (useKibana as jest.Mock).mockReturnValue({ + services: { + telemetry: { + reportEvent, + }, + }, + }); }); test('renders container elements correctly', () => { @@ -79,5 +91,8 @@ describe('EmptyResultsPrompt', () => { fireEvent.click(getByTestId(EMPTY_RESULTS_PROMPT_SCHEDULES_LINK_TEST_ID)); expect(openSchedulesFlyout).toHaveBeenCalledTimes(1); + expect(reportEvent).toHaveBeenCalledWith(AttacksEventTypes.ScheduleFlyoutOpened, { + source: 'attacks_page_empty_state', + }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/empty_results_prompt.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/empty_results_prompt.tsx index f59b695840c1a..cebac9051f85d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/empty_results_prompt.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/empty_results_prompt.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { EuiEmptyPrompt, EuiFlexGroup, @@ -17,6 +17,8 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/react'; +import { useKibana } from '../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../common/lib/telemetry'; import { IconSparkles } from '../../../../common/icons/sparkles'; import * as i18n from './translations'; @@ -39,98 +41,111 @@ interface EmptyResultsPromptProps { * It displays suggestions to help the user find results, such as adjusting the time range, filters, or checking schedules. */ export const EmptyResultsPrompt: React.FC = React.memo( - ({ openSchedulesFlyout }) => ( - - - } - title={ -

- {i18n.NO_RESULTS_MATCH_YOUR_SEARCH} -

- } - body={ - - - {i18n.HERE_ARE_SOME_THINGS_TO_TRY} + ({ openSchedulesFlyout }) => { + const { + services: { telemetry }, + } = useKibana(); + + const onScheduleLinkClick = useCallback(() => { + openSchedulesFlyout(); + telemetry.reportEvent(AttacksEventTypes.ScheduleFlyoutOpened, { + source: 'attacks_page_empty_state', + }); + }, [openSchedulesFlyout, telemetry]); -
    + + } + title={ +

    + {i18n.NO_RESULTS_MATCH_YOUR_SEARCH} +

    + } + body={ + + -
  • - {i18n.EXPAND_THE_TIME_RANGE} -
  • -
  • - {i18n.CHECK_FILTERS_CONTROLS_SEARCH_BAR} -
  • -
  • - - - - ), - }} - /> -
  • -
-
-
- } - /> -
- - + {i18n.HERE_ARE_SOME_THINGS_TO_TRY} - - - - {i18n.LEARN_MORE} - - ), - }} +
    +
  • + {i18n.EXPAND_THE_TIME_RANGE} +
  • +
  • + {i18n.CHECK_FILTERS_CONTROLS_SEARCH_BAR} +
  • +
  • + + + + ), + }} + /> +
  • +
+
+
+ } /> - - - - ) + + + + + + + + {i18n.LEARN_MORE} + + ), + }} + /> + + + + ); + } ); EmptyResultsPrompt.displayName = 'EmptyResultsPrompt'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/table_section.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/table_section.test.tsx index c9decac25a6bd..8dd8e66487c97 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/table_section.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/table_section.test.tsx @@ -24,7 +24,10 @@ import { ALERT_ATTACK_IDS } from '../../../../../common/field_maps/field_names'; import { groupingOptions, groupingSettings } from './grouping_settings/grouping_configs'; import { EmptyResultsPrompt } from './empty_results_prompt'; import { useGroupStats } from './grouping_settings/use_group_stats'; +import { useKibana } from '../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../common/lib/telemetry'; +jest.mock('../../../../common/lib/kibana'); jest.mock('@kbn/expandable-flyout'); jest.mock('../../user_info'); jest.mock('../../../containers/detection_engine/lists/use_lists_config'); @@ -75,6 +78,8 @@ const mockEmptyResultsPrompt = EmptyResultsPrompt as unknown as jest.Mock; const mockUseExpandableFlyoutApi = useExpandableFlyoutApi as jest.Mock; const mockUseGroupStats = useGroupStats as jest.Mock; +const reportEvent = jest.fn(); + const defaultProps: Parameters[0] = { assignees: [], pageFilters: [], @@ -86,6 +91,13 @@ const defaultProps: Parameters[0] = { describe('', () => { beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + telemetry: { + reportEvent, + }, + }, + }); mockUseGetDefaultGroupTitleRenderers.mockReturnValue({ defaultGroupTitleRenderers: jest.fn(), }); @@ -156,6 +168,31 @@ describe('', () => { }); }); + it('should report telemetry when openAttackDetailsFlyout is called', async () => { + mockUseAttackGroupHandler.mockReturnValue({ + getAttack: jest.fn().mockReturnValue({ id: 'attack-1' }), + isLoading: false, + }); + + render( + + + + ); + + await waitFor(() => { + expect(mockUseGetDefaultGroupTitleRenderers).toHaveBeenCalled(); + }); + + const { openAttackDetailsFlyout } = mockUseGetDefaultGroupTitleRenderers.mock.calls[0][0]; + openAttackDetailsFlyout('group-1', {}); + + expect(reportEvent).toHaveBeenCalledWith(AttacksEventTypes.DetailsFlyoutOpened, { + id: 'attack-1', + source: 'attacks_page_table', + }); + }); + it('should pass groupingOptions and groupingSettings to GroupedAlertsTable', async () => { render( diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/table_section.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/table_section.tsx index ff4314538a777..5e33c569afa70 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/table_section.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/table_section.tsx @@ -21,6 +21,8 @@ import { useDataTableFilters } from '../../../../common/hooks/use_data_table_fil import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { useGlobalTime } from '../../../../common/containers/use_global_time'; import { inputsSelectors } from '../../../../common/store/inputs'; +import { useKibana } from '../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../common/lib/telemetry'; import { useUserData } from '../../user_info'; import { useListsConfig } from '../../../containers/detection_engine/lists/use_lists_config'; import { @@ -104,6 +106,10 @@ export const TableSection = React.memo( const { to, from } = useGlobalTime(); + const { + services: { telemetry }, + } = useKibana(); + const [{ loading: userInfoLoading }] = useUserData(); const { loading: listsConfigLoading } = useListsConfig(); @@ -151,9 +157,13 @@ export const TableSection = React.memo( }, }, }); + telemetry.reportEvent(AttacksEventTypes.DetailsFlyoutOpened, { + id: attack.id, + source: 'attacks_page_table', + }); } }, - [dataView, getAttack, openFlyout] + [dataView, getAttack, openFlyout, telemetry] ); const { defaultGroupTitleRenderers } = useGetDefaultGroupTitleRenderers({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_assignees.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_assignees.test.tsx index 33ae846315c7f..3724c1718bcfe 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_assignees.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_assignees.test.tsx @@ -9,13 +9,17 @@ import { renderHook, act } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import React from 'react'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; import { useApplyAttackAssignees } from './use_apply_attack_assignees'; import { useSetUnifiedAlertsAssignees } from '../../../../../common/containers/unified_alerts/hooks/use_set_unified_alerts_assignees'; import { useUpdateAttacksModal } from '../confirmation_modal/use_update_attacks_modal'; +jest.mock('../../../../../common/lib/kibana'); jest.mock('../../../../../common/containers/unified_alerts/hooks/use_set_unified_alerts_assignees'); jest.mock('../confirmation_modal/use_update_attacks_modal'); +const mockUseKibana = useKibana as jest.MockedFunction; const mockUseSetUnifiedAlertsAssignees = useSetUnifiedAlertsAssignees as jest.MockedFunction< typeof useSetUnifiedAlertsAssignees >; @@ -32,11 +36,20 @@ function wrapper(props: { children: React.ReactNode }) { describe('useApplyAttackAssignees', () => { const mockMutateAsync = jest.fn(); const mockShowModal = jest.fn(); + const mockReportEvent = jest.fn(); beforeEach(() => { jest.clearAllMocks(); queryClient = new QueryClient(); + mockUseKibana.mockReturnValue({ + services: { + telemetry: { + reportEvent: mockReportEvent, + }, + }, + } as unknown as ReturnType); + mockUseSetUnifiedAlertsAssignees.mockReturnValue({ mutateAsync: mockMutateAsync, } as unknown as ReturnType); @@ -44,6 +57,48 @@ describe('useApplyAttackAssignees', () => { mockUseUpdateAttacksModal.mockReturnValue(mockShowModal); }); + it('should report telemetry with attack_only scope when user chooses attacks only', async () => { + mockShowModal.mockResolvedValue({ updateAlerts: false }); + mockMutateAsync.mockResolvedValue({ updated: 2 }); + + const { result } = renderHook(() => useApplyAttackAssignees(), { wrapper }); + + await act(async () => { + await result.current.applyAssignees({ + assignees: { add: ['user1'], remove: [] }, + attackIds: ['attack-1', 'attack-2'], + relatedAlertIds: ['alert-1', 'alert-2'], + telemetrySource: 'attacks_page_group_take_action', + }); + }); + + expect(mockReportEvent).toHaveBeenCalledWith(AttacksEventTypes.ActionAssigneeUpdated, { + source: 'attacks_page_group_take_action', + scope: 'attack_only', + }); + }); + + it('should report telemetry with attack_and_related_alerts scope when user chooses both', async () => { + mockShowModal.mockResolvedValue({ updateAlerts: true }); + mockMutateAsync.mockResolvedValue({ updated: 2 }); + + const { result } = renderHook(() => useApplyAttackAssignees(), { wrapper }); + + await act(async () => { + await result.current.applyAssignees({ + assignees: { add: ['user1'], remove: [] }, + attackIds: ['attack-1'], + relatedAlertIds: ['alert-1'], + telemetrySource: 'attacks_page_group_take_action', + }); + }); + + expect(mockReportEvent).toHaveBeenCalledWith(AttacksEventTypes.ActionAssigneeUpdated, { + source: 'attacks_page_group_take_action', + scope: 'attack_and_related_alerts', + }); + }); + it('should show modal and update only attacks when user chooses attacks only', async () => { mockShowModal.mockResolvedValue({ updateAlerts: false }); mockMutateAsync.mockResolvedValue({ updated: 2 }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_assignees.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_assignees.tsx index ced5a32a13bdf..968767c342d17 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_assignees.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_assignees.tsx @@ -8,6 +8,8 @@ import { useCallback } from 'react'; import type { AlertAssignees } from '../../../../../../common/api/detection_engine'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; import { useSetUnifiedAlertsAssignees } from '../../../../../common/containers/unified_alerts/hooks/use_set_unified_alerts_assignees'; import { useUpdateAttacksModal } from '../confirmation_modal/use_update_attacks_modal'; @@ -29,6 +31,9 @@ interface ApplyAttackAssigneesReturn { export const useApplyAttackAssignees = (): ApplyAttackAssigneesReturn => { const { mutateAsync: setUnifiedAlertsAssignees } = useSetUnifiedAlertsAssignees(); const showModalIfNeeded = useUpdateAttacksModal(); + const { + services: { telemetry }, + } = useKibana(); const applyAssignees = useCallback( async ({ @@ -37,6 +42,7 @@ export const useApplyAttackAssignees = (): ApplyAttackAssigneesReturn => { relatedAlertIds, setIsLoading, onSuccess, + telemetrySource, }: ApplyAttackAssigneesProps) => { // Show modal (if needed) and wait for user decision const result = await showModalIfNeeded({ @@ -47,6 +53,14 @@ export const useApplyAttackAssignees = (): ApplyAttackAssigneesReturn => { // User cancelled, don't proceed with update return; } + + if (telemetrySource) { + telemetry.reportEvent(AttacksEventTypes.ActionAssigneeUpdated, { + source: telemetrySource, + scope: result.updateAlerts ? 'attack_and_related_alerts' : 'attack_only', + }); + } + setIsLoading?.(true); try { // Combine IDs based on user choice @@ -58,7 +72,7 @@ export const useApplyAttackAssignees = (): ApplyAttackAssigneesReturn => { setIsLoading?.(false); } }, - [setUnifiedAlertsAssignees, showModalIfNeeded] + [setUnifiedAlertsAssignees, showModalIfNeeded, telemetry] ); return { applyAssignees }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_tags.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_tags.test.tsx index 00dccb4463c62..33c287ce43b74 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_tags.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_tags.test.tsx @@ -9,13 +9,17 @@ import { renderHook, act } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import React from 'react'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; import { useApplyAttackTags } from './use_apply_attack_tags'; import { useSetUnifiedAlertsTags } from '../../../../../common/containers/unified_alerts/hooks/use_set_unified_alerts_tags'; import { useUpdateAttacksModal } from '../confirmation_modal/use_update_attacks_modal'; +jest.mock('../../../../../common/lib/kibana'); jest.mock('../../../../../common/containers/unified_alerts/hooks/use_set_unified_alerts_tags'); jest.mock('../confirmation_modal/use_update_attacks_modal'); +const mockUseKibana = useKibana as jest.MockedFunction; const mockUseSetUnifiedAlertsTags = useSetUnifiedAlertsTags as jest.MockedFunction< typeof useSetUnifiedAlertsTags >; @@ -32,11 +36,20 @@ function wrapper(props: { children: React.ReactNode }) { describe('useApplyAttackTags', () => { const mockMutateAsync = jest.fn(); const mockShowModal = jest.fn(); + const mockReportEvent = jest.fn(); beforeEach(() => { jest.clearAllMocks(); queryClient = new QueryClient(); + mockUseKibana.mockReturnValue({ + services: { + telemetry: { + reportEvent: mockReportEvent, + }, + }, + } as unknown as ReturnType); + mockUseSetUnifiedAlertsTags.mockReturnValue({ mutateAsync: mockMutateAsync, } as unknown as ReturnType); @@ -44,6 +57,48 @@ describe('useApplyAttackTags', () => { mockUseUpdateAttacksModal.mockReturnValue(mockShowModal); }); + it('should report telemetry with attack_only scope when user chooses attacks only', async () => { + mockShowModal.mockResolvedValue({ updateAlerts: false }); + mockMutateAsync.mockResolvedValue({ updated: 2 }); + + const { result } = renderHook(() => useApplyAttackTags(), { wrapper }); + + await act(async () => { + await result.current.applyTags({ + tags: { tags_to_add: ['tag1'], tags_to_remove: [] }, + attackIds: ['attack-1', 'attack-2'], + relatedAlertIds: ['alert-1', 'alert-2'], + telemetrySource: 'attacks_page_group_take_action', + }); + }); + + expect(mockReportEvent).toHaveBeenCalledWith(AttacksEventTypes.ActionTagsUpdated, { + source: 'attacks_page_group_take_action', + scope: 'attack_only', + }); + }); + + it('should report telemetry with attack_and_related_alerts scope when user chooses both', async () => { + mockShowModal.mockResolvedValue({ updateAlerts: true }); + mockMutateAsync.mockResolvedValue({ updated: 4 }); + + const { result } = renderHook(() => useApplyAttackTags(), { wrapper }); + + await act(async () => { + await result.current.applyTags({ + tags: { tags_to_add: ['tag1'], tags_to_remove: ['tag2'] }, + attackIds: ['attack-1'], + relatedAlertIds: ['alert-1', 'alert-2', 'alert-3'], + telemetrySource: 'attacks_page_group_take_action', + }); + }); + + expect(mockReportEvent).toHaveBeenCalledWith(AttacksEventTypes.ActionTagsUpdated, { + source: 'attacks_page_group_take_action', + scope: 'attack_and_related_alerts', + }); + }); + it('should show modal and update only attacks when user chooses attacks only', async () => { mockShowModal.mockResolvedValue({ updateAlerts: false }); mockMutateAsync.mockResolvedValue({ updated: 2 }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_tags.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_tags.tsx index 3b7ff5b01423b..8aac5e61aa286 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_tags.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_tags.tsx @@ -7,6 +7,8 @@ import { useCallback } from 'react'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; import { useSetUnifiedAlertsTags } from '../../../../../common/containers/unified_alerts/hooks/use_set_unified_alerts_tags'; import { useUpdateAttacksModal } from '../confirmation_modal/use_update_attacks_modal'; @@ -28,9 +30,19 @@ interface ApplyAttackTagsReturn { export const useApplyAttackTags = (): ApplyAttackTagsReturn => { const { mutateAsync: setUnifiedAlertsTags } = useSetUnifiedAlertsTags(); const showModalIfNeeded = useUpdateAttacksModal(); + const { + services: { telemetry }, + } = useKibana(); const applyTags = useCallback( - async ({ tags, attackIds, relatedAlertIds, setIsLoading, onSuccess }: ApplyAttackTagsProps) => { + async ({ + tags, + attackIds, + relatedAlertIds, + setIsLoading, + onSuccess, + telemetrySource, + }: ApplyAttackTagsProps) => { // Show modal (if needed) and wait for user decision const result = await showModalIfNeeded({ alertsCount: relatedAlertIds.length, @@ -40,6 +52,14 @@ export const useApplyAttackTags = (): ApplyAttackTagsReturn => { // User cancelled, don't proceed with update return; } + + if (telemetrySource) { + telemetry.reportEvent(AttacksEventTypes.ActionTagsUpdated, { + source: telemetrySource, + scope: result.updateAlerts ? 'attack_and_related_alerts' : 'attack_only', + }); + } + setIsLoading?.(true); try { // Combine IDs based on user choice @@ -54,7 +74,7 @@ export const useApplyAttackTags = (): ApplyAttackTagsReturn => { setIsLoading?.(false); } }, - [setUnifiedAlertsTags, showModalIfNeeded] + [setUnifiedAlertsTags, showModalIfNeeded, telemetry] ); return { applyTags }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_workflow_status.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_workflow_status.test.tsx index 89ea52a801f60..77b079f271cbb 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_workflow_status.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_workflow_status.test.tsx @@ -9,17 +9,21 @@ import { renderHook, act } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import React from 'react'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; import { FILTER_CLOSED, FILTER_OPEN } from '../../../../../../common/types'; import type { AlertWorkflowStatus } from '../../../../../common/types'; import { useApplyAttackWorkflowStatus } from './use_apply_attack_workflow_status'; import { useSetUnifiedAlertsWorkflowStatus } from '../../../../../common/containers/unified_alerts/hooks/use_set_unified_alerts_workflow_status'; import { useUpdateAttacksModal } from '../confirmation_modal/use_update_attacks_modal'; +jest.mock('../../../../../common/lib/kibana'); jest.mock( '../../../../../common/containers/unified_alerts/hooks/use_set_unified_alerts_workflow_status' ); jest.mock('../confirmation_modal/use_update_attacks_modal'); +const mockUseKibana = useKibana as jest.MockedFunction; const mockUseSetUnifiedAlertsWorkflowStatus = useSetUnifiedAlertsWorkflowStatus as jest.MockedFunction< typeof useSetUnifiedAlertsWorkflowStatus @@ -37,11 +41,20 @@ function wrapper(props: { children: React.ReactNode }) { describe('useApplyAttackWorkflowStatus', () => { const mockMutateAsync = jest.fn(); const mockShowModal = jest.fn(); + const mockReportEvent = jest.fn(); beforeEach(() => { jest.clearAllMocks(); queryClient = new QueryClient(); + mockUseKibana.mockReturnValue({ + services: { + telemetry: { + reportEvent: mockReportEvent, + }, + }, + } as unknown as ReturnType); + mockUseSetUnifiedAlertsWorkflowStatus.mockReturnValue({ mutateAsync: mockMutateAsync, } as unknown as ReturnType); @@ -49,6 +62,50 @@ describe('useApplyAttackWorkflowStatus', () => { mockUseUpdateAttacksModal.mockReturnValue(mockShowModal); }); + it('should report telemetry with attack_only scope when user chooses attacks only', async () => { + mockShowModal.mockResolvedValue({ updateAlerts: false }); + mockMutateAsync.mockResolvedValue({ updated: 2 }); + + const { result } = renderHook(() => useApplyAttackWorkflowStatus(), { wrapper }); + + await act(async () => { + await result.current.applyWorkflowStatus({ + status: FILTER_OPEN as AlertWorkflowStatus, + attackIds: ['attack-1', 'attack-2'], + relatedAlertIds: ['alert-1', 'alert-2'], + telemetrySource: 'attacks_page_group_take_action', + }); + }); + + expect(mockReportEvent).toHaveBeenCalledWith(AttacksEventTypes.ActionStatusUpdated, { + status: FILTER_OPEN, + source: 'attacks_page_group_take_action', + scope: 'attack_only', + }); + }); + + it('should report telemetry with attack_and_related_alerts scope when user chooses both', async () => { + mockShowModal.mockResolvedValue({ updateAlerts: true }); + mockMutateAsync.mockResolvedValue({ updated: 2 }); + + const { result } = renderHook(() => useApplyAttackWorkflowStatus(), { wrapper }); + + await act(async () => { + await result.current.applyWorkflowStatus({ + status: FILTER_OPEN as AlertWorkflowStatus, + attackIds: ['attack-1'], + relatedAlertIds: ['alert-1', 'alert-2', 'alert-3'], + telemetrySource: 'attacks_page_group_take_action', + }); + }); + + expect(mockReportEvent).toHaveBeenCalledWith(AttacksEventTypes.ActionStatusUpdated, { + status: FILTER_OPEN, + source: 'attacks_page_group_take_action', + scope: 'attack_and_related_alerts', + }); + }); + it('should show modal and update only attacks when user chooses attacks only', async () => { mockShowModal.mockResolvedValue({ updateAlerts: false }); mockMutateAsync.mockResolvedValue({ updated: 2 }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_workflow_status.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_workflow_status.tsx index 081e076f81bb7..39218d423b221 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_workflow_status.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/apply_actions/use_apply_attack_workflow_status.tsx @@ -7,6 +7,8 @@ import { useCallback } from 'react'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; import { FILTER_CLOSED } from '../../../../../../common/types'; import type { AlertClosingReason } from '../../../../../../common/types'; import type { AlertWorkflowStatus } from '../../../../../common/types'; @@ -33,6 +35,9 @@ interface ApplyAttackWorkflowStatusReturn { export const useApplyAttackWorkflowStatus = (): ApplyAttackWorkflowStatusReturn => { const { mutateAsync: setUnifiedAlertsWorkflowStatus } = useSetUnifiedAlertsWorkflowStatus(); const showModalIfNeeded = useUpdateAttacksModal(); + const { + services: { telemetry }, + } = useKibana(); const applyWorkflowStatus = useCallback( async ({ @@ -42,6 +47,7 @@ export const useApplyAttackWorkflowStatus = (): ApplyAttackWorkflowStatusReturn relatedAlertIds, setIsLoading, onSuccess, + telemetrySource, }: ApplyAttackWorkflowStatusProps) => { // Show modal (if needed) and wait for user decision const result = await showModalIfNeeded({ @@ -52,6 +58,15 @@ export const useApplyAttackWorkflowStatus = (): ApplyAttackWorkflowStatusReturn // User cancelled, don't proceed with update return; } + + if (telemetrySource) { + telemetry.reportEvent(AttacksEventTypes.ActionStatusUpdated, { + status, + source: telemetrySource, + scope: result.updateAlerts ? 'attack_and_related_alerts' : 'attack_only', + }); + } + setIsLoading?.(true); try { // Combine IDs based on user choice @@ -67,7 +82,7 @@ export const useApplyAttackWorkflowStatus = (): ApplyAttackWorkflowStatusReturn setIsLoading?.(false); } }, - [setUnifiedAlertsWorkflowStatus, showModalIfNeeded] + [setUnifiedAlertsWorkflowStatus, showModalIfNeeded, telemetry] ); return { applyWorkflowStatus }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_assignees_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_assignees_items.test.tsx index 0a86eb2d93481..481a3f85ce287 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_assignees_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_assignees_items.test.tsx @@ -33,6 +33,8 @@ function wrapper(props: { children: React.ReactNode }) { } describe('useBulkAttackAssigneesItems', () => { + const mockApplyAssignees = jest.fn(); + beforeEach(() => { jest.clearAllMocks(); queryClient = new QueryClient(); @@ -48,7 +50,7 @@ describe('useBulkAttackAssigneesItems', () => { } as unknown as ReturnType); mockUseApplyAttackAssignees.mockReturnValue({ - applyAssignees: jest.fn(), + applyAssignees: mockApplyAssignees, } as ReturnType); }); @@ -97,4 +99,50 @@ describe('useBulkAttackAssigneesItems', () => { expect(result.current.panels.length).toBeGreaterThan(0); }); + + describe('actions', () => { + it('should call applyAssignees with telemetrySource when removing all assignees', async () => { + const { result } = renderHook( + () => + useBulkAttackAssigneesItems({ + telemetrySource: 'attacks_page_group_take_action', + alertAssignments: ['user-1'], + }), + { wrapper } + ); + + const removeAllItem = result.current.items.find( + (item) => item.key === 'remove-all-attack-assignees' + ); + + expect(removeAllItem).toBeDefined(); + + if (removeAllItem && removeAllItem.onClick) { + await removeAllItem.onClick( + [ + { + _id: '1', + data: [ + { + field: 'kibana.alert.workflow_assignee_ids', + value: ['user-1'], + }, + ], + ecs: { _id: '1' }, + }, + ], + false, + jest.fn(), + jest.fn(), + jest.fn() + ); + } + + expect(mockApplyAssignees).toHaveBeenCalledWith( + expect.objectContaining({ + telemetrySource: 'attacks_page_group_take_action', + }) + ); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_assignees_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_assignees_items.tsx index 65dda9a1a3c30..c1444b8c019f7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_assignees_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_assignees_items.tsx @@ -16,6 +16,7 @@ import type { RenderContentPanelProps, } from '@kbn/response-ops-alerts-table/types'; +import type { AttacksActionTelemetrySource } from '../../../../../common/lib/telemetry'; import { useLicense } from '../../../../../common/hooks/use_license'; import { ASSIGNEES_PANEL_WIDTH } from '../../../../../common/components/assignees/constants'; import { BulkAlertAssigneesPanel } from '../../../../../common/components/toolbar/bulk_actions/alert_bulk_assignees'; @@ -30,6 +31,8 @@ export interface UseBulkAttackAssigneesItemsProps { onAssigneesUpdate?: () => void; /** Current alert assignments */ alertAssignments?: string[]; + /** Source of the action for telemetry */ + telemetrySource?: AttacksActionTelemetrySource; } /** @@ -40,6 +43,7 @@ export interface UseBulkAttackAssigneesItemsProps { export const useBulkAttackAssigneesItems = ({ onAssigneesUpdate, alertAssignments, + telemetrySource, }: UseBulkAttackAssigneesItemsProps = {}): BulkAttackActionItems => { const isPlatinumPlus = useLicense().isPlatinumPlus(); const { hasIndexWrite, hasAttackIndexWrite, loading } = useAttacksPrivileges(); @@ -71,9 +75,10 @@ export const useBulkAttackAssigneesItems = ({ relatedAlertIds, setIsLoading, onSuccess: onAssigneesUpdate, + telemetrySource, }); }, - [applyAssignees, onAssigneesUpdate] + [applyAssignees, onAssigneesUpdate, telemetrySource] ); const attackAssigneesItems: BulkActionsConfig[] = useMemo(() => { @@ -151,12 +156,13 @@ export const useBulkAttackAssigneesItems = ({ relatedAlertIds, setIsLoading, onSuccess: onSuccessCallback, + telemetrySource, }); }} /> ); }, - [onAssigneesUpdate, applyAssignees] + [onAssigneesUpdate, applyAssignees, telemetrySource] ); const attackAssigneesPanels: AttackContentPanelConfig[] = useMemo( diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_case_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_case_items.test.tsx index 62be6545d430b..85ef0db3378ff 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_case_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_case_items.test.tsx @@ -13,6 +13,7 @@ import { ALERT_ATTACK_DISCOVERY_ALERT_IDS, ALERT_ATTACK_DISCOVERY_MARKDOWN_COMMENT, } from '../constants'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; jest.mock('../../../../../common/lib/kibana', () => ({ useKibana: jest.fn(), @@ -46,13 +47,18 @@ function wrapper(props: { children: React.ReactNode }) { describe('useBulkAttackCaseItems', () => { const onAddToNewCase = jest.fn(); const onAddToExistingCase = jest.fn(); + const reportEventMock = jest.fn(); beforeEach(() => { jest.clearAllMocks(); + reportEventMock.mockClear(); queryClient = new QueryClient(); useKibana.mockReturnValue({ services: { + telemetry: { + reportEvent: reportEventMock, + }, cases: { helpers: { canUseCases: jest.fn().mockReturnValue({ @@ -86,6 +92,9 @@ describe('useBulkAttackCaseItems', () => { it('should return empty items when user lacks cases permissions', () => { useKibana.mockReturnValue({ services: { + telemetry: { + reportEvent: reportEventMock, + }, cases: { helpers: { canUseCases: jest.fn().mockReturnValue({ @@ -145,6 +154,38 @@ describe('useBulkAttackCaseItems', () => { expect(closePopover).toHaveBeenCalledTimes(1); }); + it('should report ActionAddedToCase event when adding to new case', async () => { + const { result } = renderHook( + () => + useBulkAttackCaseItems({ + title: 'attack title', + telemetrySource: 'attacks_page_group_take_action', + }), + { + wrapper, + } + ); + + await result.current.items[0]?.onClick?.( + [ + { + _id: 'attack-1', + data: [], + ecs: { _id: 'attack-1' }, + }, + ], + false, + jest.fn(), + jest.fn(), + jest.fn() + ); + + expect(reportEventMock).toHaveBeenCalledWith(AttacksEventTypes.ActionAddedToCase, { + source: 'attacks_page_group_take_action', + action: 'add_to_new_case', + }); + }); + it('should pass unique alert ids and markdown comments to onAddToExistingCase', async () => { const closePopover = jest.fn(); const { result } = renderHook( @@ -186,6 +227,38 @@ describe('useBulkAttackCaseItems', () => { expect(closePopover).toHaveBeenCalledTimes(1); }); + it('should report ActionAddedToCase event when adding to existing case', async () => { + const { result } = renderHook( + () => + useBulkAttackCaseItems({ + title: 'attack title', + telemetrySource: 'attacks_page_group_take_action', + }), + { + wrapper, + } + ); + + await result.current.items[1]?.onClick?.( + [ + { + _id: 'attack-1', + data: [], + ecs: { _id: 'attack-1' }, + }, + ], + false, + jest.fn(), + jest.fn(), + jest.fn() + ); + + expect(reportEventMock).toHaveBeenCalledWith(AttacksEventTypes.ActionAddedToCase, { + source: 'attacks_page_group_take_action', + action: 'add_to_existing_case', + }); + }); + it('should return empty panels', () => { const { result } = renderHook(() => useBulkAttackCaseItems({ title: 'attack title' }), { wrapper, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_case_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_case_items.tsx index c942593e5b64e..aefd464880476 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_case_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_case_items.tsx @@ -7,10 +7,13 @@ import { useCallback, useMemo } from 'react'; import type { BulkActionsConfig } from '@kbn/response-ops-alerts-table/types'; + import { useAddToExistingCase } from '../../../../../attack_discovery/pages/results/take_action/use_add_to_existing_case'; import { useAddToNewCase } from '../../../../../attack_discovery/pages/results/take_action/use_add_to_case'; import { APP_ID } from '../../../../../../common'; import { useKibana } from '../../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; +import type { AttacksActionTelemetrySource } from '../../../../../common/lib/telemetry'; import { ADD_TO_EXISTING_CASE, ADD_TO_NEW_CASE } from '../translations'; import { ALERT_ATTACK_DISCOVERY_MARKDOWN_COMMENT } from '../constants'; import type { BulkAttackActionItems } from '../types'; @@ -23,6 +26,8 @@ export interface UseBulkAttackCaseItemsProps { onCasesAdd?: () => void; /** Optional callback to close the popover after triggering action */ closePopover?: () => void; + /** Source of the action for telemetry */ + telemetrySource?: AttacksActionTelemetrySource; } /** @@ -32,9 +37,10 @@ export const useBulkAttackCaseItems = ({ title, onCasesAdd, closePopover, + telemetrySource, }: UseBulkAttackCaseItemsProps): BulkAttackActionItems => { const { - services: { cases }, + services: { cases, telemetry }, } = useKibana(); const userCasesPermissions = cases.helpers.canUseCases([APP_ID]); const canCreateAndReadCases = userCasesPermissions.createComment && userCasesPermissions.read; @@ -69,10 +75,17 @@ export const useBulkAttackCaseItems = ({ }) .filter((comment): comment is string => comment != null); + if (telemetrySource) { + telemetry.reportEvent(AttacksEventTypes.ActionAddedToCase, { + source: telemetrySource, + action: 'add_to_new_case', + }); + } + onAddToNewCase({ alertIds, markdownComments }); closePopover?.(); }, - [closePopover, onAddToNewCase] + [closePopover, onAddToNewCase, telemetrySource, telemetry] ); const onAddToExistingCaseClick = useCallback['onClick']>( @@ -90,10 +103,17 @@ export const useBulkAttackCaseItems = ({ }) .filter((comment): comment is string => comment != null); + if (telemetrySource) { + telemetry.reportEvent(AttacksEventTypes.ActionAddedToCase, { + source: telemetrySource, + action: 'add_to_existing_case', + }); + } + onAddToExistingCase({ alertIds, markdownComments }); closePopover?.(); }, - [closePopover, onAddToExistingCase] + [closePopover, onAddToExistingCase, telemetrySource, telemetry] ); const items = useMemo( diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_investigate_in_timeline_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_investigate_in_timeline_items.test.tsx index 9ea36b533e9fa..fd437c78e92de 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_investigate_in_timeline_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_investigate_in_timeline_items.test.tsx @@ -12,18 +12,30 @@ import { useBulkAttackInvestigateInTimelineItems } from './use_bulk_attack_inves import { useUserPrivileges } from '../../../../../common/components/user_privileges'; import { useInvestigateInTimeline } from '../../../../../common/hooks/timeline/use_investigate_in_timeline'; import { ALERT_ATTACK_DISCOVERY_ALERT_IDS } from '../constants'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; jest.mock('../../../../../common/components/user_privileges'); jest.mock('../../../../../common/hooks/timeline/use_investigate_in_timeline'); jest.mock('../../../../components/alerts_table/actions', () => ({ buildAlertsKqlFilter: jest.fn().mockReturnValue([]), })); +jest.mock('../../../../../common/lib/kibana'); const mockUseUserPrivileges = useUserPrivileges as jest.MockedFunction; const mockUseInvestigateInTimeline = useInvestigateInTimeline as jest.MockedFunction< typeof useInvestigateInTimeline >; +const reportEventMock = jest.fn(); +(useKibana as jest.Mock).mockReturnValue({ + services: { + telemetry: { + reportEvent: reportEventMock, + }, + }, +}); + let queryClient: QueryClient; function wrapper(props: { children: React.ReactNode }) { @@ -33,6 +45,7 @@ function wrapper(props: { children: React.ReactNode }) { describe('useBulkAttackInvestigateInTimelineItems', () => { beforeEach(() => { jest.clearAllMocks(); + reportEventMock.mockClear(); queryClient = new QueryClient(); mockUseInvestigateInTimeline.mockReturnValue({ @@ -86,6 +99,40 @@ describe('useBulkAttackInvestigateInTimelineItems', () => { expect(closePopover).toHaveBeenCalledTimes(1); }); + it('should report TimelineInvestigationOpened event on click', async () => { + const investigateInTimeline = jest.fn(); + mockUseInvestigateInTimeline.mockReturnValue({ + investigateInTimeline, + } as unknown as ReturnType); + + const { result } = renderHook( + () => + useBulkAttackInvestigateInTimelineItems({ + telemetrySource: 'attacks_page_group_take_action', + }), + { + wrapper, + } + ); + await result.current.items[0]?.onClick?.( + [ + { + _id: 'attack-1', + data: [{ field: ALERT_ATTACK_DISCOVERY_ALERT_IDS, value: ['alert-1'] }], + ecs: { _id: 'attack-1' }, + }, + ], + false, + jest.fn(), + jest.fn(), + jest.fn() + ); + + expect(reportEventMock).toHaveBeenCalledWith(AttacksEventTypes.TimelineInvestigationOpened, { + source: 'attacks_page_group_take_action', + }); + }); + it('should return empty panels', () => { const { result } = renderHook(() => useBulkAttackInvestigateInTimelineItems(), { wrapper }); expect(result.current.panels).toEqual([]); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_investigate_in_timeline_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_investigate_in_timeline_items.tsx index bcabc0b8a77a3..a7a24ff0151da 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_investigate_in_timeline_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_investigate_in_timeline_items.tsx @@ -7,16 +7,21 @@ import { useCallback, useMemo } from 'react'; import type { BulkActionsConfig } from '@kbn/response-ops-alerts-table/types'; +import { useKibana } from '../../../../../common/lib/kibana'; import { useUserPrivileges } from '../../../../../common/components/user_privileges'; import { useInvestigateInTimeline } from '../../../../../common/hooks/timeline/use_investigate_in_timeline'; import { buildAlertsKqlFilter } from '../../../../components/alerts_table/actions'; import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../components/alerts_table/translations'; import type { BulkAttackActionItems } from '../types'; import { extractRelatedDetectionAlertIds } from '../utils/extract_related_detection_alert_ids'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; +import type { AttacksActionTelemetrySource } from '../../../../../common/lib/telemetry'; export interface UseBulkAttackInvestigateInTimelineItemsProps { /** Optional callback to close the popover after triggering action */ closePopover?: () => void; + /** Source of the action for telemetry */ + telemetrySource?: AttacksActionTelemetrySource; } /** @@ -24,23 +29,33 @@ export interface UseBulkAttackInvestigateInTimelineItemsProps { */ export const useBulkAttackInvestigateInTimelineItems = ({ closePopover, + telemetrySource, }: UseBulkAttackInvestigateInTimelineItemsProps = {}): BulkAttackActionItems => { const { investigateInTimeline } = useInvestigateInTimeline(); const { timelinePrivileges: { read: canUseTimeline }, } = useUserPrivileges(); + const { + services: { telemetry }, + } = useKibana(); const onInvestigateInTimelineClick = useCallback['onClick']>( async (alertItems) => { const alertIds = extractRelatedDetectionAlertIds(alertItems); const alertIdFilters = buildAlertsKqlFilter('_id', alertIds); + if (telemetrySource) { + telemetry.reportEvent(AttacksEventTypes.TimelineInvestigationOpened, { + source: telemetrySource, + }); + } + investigateInTimeline({ filters: alertIdFilters, }); closePopover?.(); }, - [closePopover, investigateInTimeline] + [closePopover, investigateInTimeline, telemetrySource, telemetry] ); const items = useMemo( diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_tags_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_tags_items.test.tsx index f560fbe72364a..4e02686ed7a2b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_tags_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_tags_items.test.tsx @@ -5,21 +5,26 @@ * 2.0. */ -import { renderHook } from '@testing-library/react'; +import { render, renderHook } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import React from 'react'; import { useBulkAttackTagsItems } from './use_bulk_attack_tags_items'; import { useAttacksPrivileges } from '../use_attacks_privileges'; import { useApplyAttackTags } from '../apply_actions/use_apply_attack_tags'; +import { BulkAlertTagsPanel } from '../../../../../common/components/toolbar/bulk_actions/alert_bulk_tags'; jest.mock('../use_attacks_privileges'); jest.mock('../apply_actions/use_apply_attack_tags'); +jest.mock('../../../../../common/components/toolbar/bulk_actions/alert_bulk_tags', () => ({ + BulkAlertTagsPanel: jest.fn(() => null), +})); const mockUseAttacksPrivileges = useAttacksPrivileges as jest.MockedFunction< typeof useAttacksPrivileges >; const mockUseApplyAttackTags = useApplyAttackTags as jest.MockedFunction; +const mockBulkAlertTagsPanel = BulkAlertTagsPanel as jest.MockedFunction; let queryClient: QueryClient; @@ -28,6 +33,8 @@ function wrapper(props: { children: React.ReactNode }) { } describe('useBulkAttackTagsItems', () => { + const mockApplyTags = jest.fn(); + beforeEach(() => { jest.clearAllMocks(); queryClient = new QueryClient(); @@ -39,7 +46,7 @@ describe('useBulkAttackTagsItems', () => { }); mockUseApplyAttackTags.mockReturnValue({ - applyTags: jest.fn(), + applyTags: mockApplyTags, } as ReturnType); }); @@ -90,4 +97,33 @@ describe('useBulkAttackTagsItems', () => { expect(result.current.panels.length).toBeGreaterThan(0); }); + + it('should call applyTags with telemetrySource when tags are submitted', async () => { + const { result } = renderHook( + () => useBulkAttackTagsItems({ telemetrySource: 'attacks_page_group_take_action' }), + { wrapper } + ); + + const panel = result.current.panels[0]; + if (panel && panel.renderContent) { + render( + panel.renderContent({ + alertItems: [{ _id: '1', data: [], ecs: { _id: '1' } }], + closePopoverMenu: jest.fn(), + setIsBulkActionsLoading: jest.fn(), + }) + ); + } + + const onSubmit = mockBulkAlertTagsPanel.mock.calls[0][0].onSubmit; + if (onSubmit) { + await onSubmit({ tags_to_add: ['tag1'], tags_to_remove: ['tag2'] }, [], jest.fn(), jest.fn()); + } + + expect(mockApplyTags).toHaveBeenCalledWith( + expect.objectContaining({ + telemetrySource: 'attacks_page_group_take_action', + }) + ); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_tags_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_tags_items.tsx index ae7fd2abd9746..5bda744bd754e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_tags_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_tags_items.tsx @@ -11,6 +11,8 @@ import type { BulkActionsConfig, RenderContentPanelProps, } from '@kbn/response-ops-alerts-table/types'; + +import type { AttacksActionTelemetrySource } from '../../../../../common/lib/telemetry'; import { BulkAlertTagsPanel } from '../../../../../common/components/toolbar/bulk_actions/alert_bulk_tags'; import { useAttacksPrivileges } from '../use_attacks_privileges'; import { extractRelatedDetectionAlertIds } from '../utils/extract_related_detection_alert_ids'; @@ -21,6 +23,8 @@ import type { AttackContentPanelConfig, BulkAttackActionItems } from '../types'; export interface UseBulkAttackTagsItemsProps { /** Optional callback when tags are updated */ onTagsUpdate?: () => void; + /** Source of the action for telemetry */ + telemetrySource?: AttacksActionTelemetrySource; } /** @@ -30,6 +34,7 @@ export interface UseBulkAttackTagsItemsProps { */ export const useBulkAttackTagsItems = ({ onTagsUpdate, + telemetrySource, }: UseBulkAttackTagsItemsProps = {}): BulkAttackActionItems => { const { hasIndexWrite, hasAttackIndexWrite, loading } = useAttacksPrivileges(); const { applyTags } = useApplyAttackTags(); @@ -94,12 +99,13 @@ export const useBulkAttackTagsItems = ({ relatedAlertIds, setIsLoading, onSuccess, + telemetrySource, }); }} /> ); }, - [applyTags, onTagsUpdate] + [applyTags, onTagsUpdate, telemetrySource] ); const attackTagsPanels: AttackContentPanelConfig[] = useMemo( diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_workflow_status_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_workflow_status_items.test.tsx index 9a825b4dd6b7b..f8f92b11b8fbf 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_workflow_status_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_workflow_status_items.test.tsx @@ -13,8 +13,13 @@ import { useBulkAttackWorkflowStatusItems } from './use_bulk_attack_workflow_sta import { useAttacksPrivileges } from '../use_attacks_privileges'; import { useApplyAttackWorkflowStatus } from '../apply_actions/use_apply_attack_workflow_status'; +import { useBulkAlertClosingReasonItems } from '../../../../../common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items'; + jest.mock('../use_attacks_privileges'); jest.mock('../apply_actions/use_apply_attack_workflow_status'); +jest.mock( + '../../../../../common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items' +); const mockUseAttacksPrivileges = useAttacksPrivileges as jest.MockedFunction< typeof useAttacksPrivileges @@ -22,6 +27,9 @@ const mockUseAttacksPrivileges = useAttacksPrivileges as jest.MockedFunction< const mockUseApplyAttackWorkflowStatus = useApplyAttackWorkflowStatus as jest.MockedFunction< typeof useApplyAttackWorkflowStatus >; +const mockUseBulkAlertClosingReasonItems = useBulkAlertClosingReasonItems as jest.MockedFunction< + typeof useBulkAlertClosingReasonItems +>; let queryClient: QueryClient; @@ -30,10 +38,22 @@ function wrapper(props: { children: React.ReactNode }) { } describe('useBulkAttackWorkflowStatusItems', () => { + const mockApplyWorkflowStatus = jest.fn(); + beforeEach(() => { jest.clearAllMocks(); queryClient = new QueryClient(); + mockUseBulkAlertClosingReasonItems.mockReturnValue({ + item: { + label: 'Close', + key: 'closed-attack-status', + disableOnQuery: true, + }, + panels: [], + getPanels: jest.fn().mockReturnValue([]), + }); + mockUseAttacksPrivileges.mockReturnValue({ hasIndexWrite: true, hasAttackIndexWrite: true, @@ -41,7 +61,7 @@ describe('useBulkAttackWorkflowStatusItems', () => { }); mockUseApplyAttackWorkflowStatus.mockReturnValue({ - applyWorkflowStatus: jest.fn(), + applyWorkflowStatus: mockApplyWorkflowStatus, } as ReturnType); }); @@ -90,4 +110,93 @@ describe('useBulkAttackWorkflowStatusItems', () => { expect(result.current.panels).toEqual([]); }); + + describe('actions', () => { + it('should call applyWorkflowStatus with telemetrySource when opening attacks', async () => { + const { result } = renderHook( + () => + useBulkAttackWorkflowStatusItems({ + telemetrySource: 'attacks_page_group_take_action', + currentStatus: 'closed', + }), + { wrapper } + ); + + const openItem = result.current.items.find((item) => item.key === 'open-attack-status'); + if (openItem && openItem.onClick) { + await openItem.onClick( + [{ _id: '1', data: [], ecs: { _id: '1' } }], + false, + jest.fn(), + jest.fn(), + jest.fn() + ); + } + + expect(mockApplyWorkflowStatus).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'open', + telemetrySource: 'attacks_page_group_take_action', + }) + ); + }); + + it('should call applyWorkflowStatus with telemetrySource when acknowledging attacks', async () => { + const { result } = renderHook( + () => + useBulkAttackWorkflowStatusItems({ + telemetrySource: 'attacks_page_group_take_action', + currentStatus: 'open', + }), + { wrapper } + ); + + const ackItem = result.current.items.find((item) => item.key === 'acknowledge-attack-status'); + if (ackItem && ackItem.onClick) { + await ackItem.onClick( + [{ _id: '1', data: [], ecs: { _id: '1' } }], + false, + jest.fn(), + jest.fn(), + jest.fn() + ); + } + + expect(mockApplyWorkflowStatus).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'acknowledged', + telemetrySource: 'attacks_page_group_take_action', + }) + ); + }); + + it('should call applyWorkflowStatus with telemetrySource when closing attacks', async () => { + renderHook( + () => + useBulkAttackWorkflowStatusItems({ + telemetrySource: 'attacks_page_group_take_action', + currentStatus: 'open', + }), + { wrapper } + ); + + const onSubmitCloseReason = + mockUseBulkAlertClosingReasonItems.mock.calls[0][0]?.onSubmitCloseReason; + if (onSubmitCloseReason) { + await onSubmitCloseReason({ + alertItems: [{ _id: '1', data: [], ecs: { _id: '1' } }], + reason: 'other', + setIsBulkActionsLoading: jest.fn(), + closePopoverMenu: jest.fn(), + }); + } + + expect(mockApplyWorkflowStatus).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'closed', + telemetrySource: 'attacks_page_group_take_action', + }) + ); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_workflow_status_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_workflow_status_items.tsx index 8df3e9e474479..265ad2c3b3df6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_workflow_status_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_workflow_status_items.tsx @@ -8,6 +8,8 @@ import { useCallback, useMemo } from 'react'; import type { BulkActionsConfig } from '@kbn/response-ops-alerts-table/types'; import type { TimelineItem } from '@kbn/timelines-plugin/common'; + +import type { AttacksActionTelemetrySource } from '../../../../../common/lib/telemetry'; import type { AlertWorkflowStatus } from '../../../../../common/types'; import type { AlertClosingReason } from '../../../../../../common/types'; import { FILTER_ACKNOWLEDGED, FILTER_CLOSED, FILTER_OPEN } from '../../../../../../common/types'; @@ -23,6 +25,8 @@ export interface UseBulkAttackWorkflowStatusItemsProps { onWorkflowStatusUpdate?: () => void; /** Current workflow status of selected alerts */ currentStatus?: AlertWorkflowStatus; + /** Source of the action for telemetry */ + telemetrySource?: AttacksActionTelemetrySource; } /** @@ -32,6 +36,7 @@ export interface UseBulkAttackWorkflowStatusItemsProps { export const useBulkAttackWorkflowStatusItems = ({ onWorkflowStatusUpdate, currentStatus, + telemetrySource, }: UseBulkAttackWorkflowStatusItemsProps = {}): BulkAttackActionItems => { const { hasIndexWrite, hasAttackIndexWrite, loading } = useAttacksPrivileges(); const { applyWorkflowStatus } = useApplyAttackWorkflowStatus(); @@ -49,10 +54,11 @@ export const useBulkAttackWorkflowStatusItems = ({ relatedAlertIds, setIsLoading: setAlertLoading, onSuccess: onWorkflowStatusUpdate, + telemetrySource, }); }) as Required['onClick']; }, - [applyWorkflowStatus, onWorkflowStatusUpdate] + [applyWorkflowStatus, onWorkflowStatusUpdate, telemetrySource] ); const onSubmitCloseReason = useCallback( @@ -66,9 +72,10 @@ export const useBulkAttackWorkflowStatusItems = ({ attackIds, relatedAlertIds, onSuccess: onWorkflowStatusUpdate, + telemetrySource, }); }, - [applyWorkflowStatus, onWorkflowStatusUpdate] + [applyWorkflowStatus, onWorkflowStatusUpdate, telemetrySource] ); const { item: alertClosingReasonItem, panels: alertClosingReasonPanels } = diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_assignees_context_menu_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_assignees_context_menu_items.test.tsx index 4c075749e333e..1971fc8d8fc7d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_assignees_context_menu_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_assignees_context_menu_items.test.tsx @@ -48,6 +48,7 @@ describe('useAttackAssigneesContextMenuItems', () => { closePopover: mockClosePopover, setIsLoading: mockSetIsLoading, onSuccess: mockOnSuccess, + telemetrySource: 'attacks_page_group_take_action' as const, }; beforeEach(() => { @@ -103,6 +104,7 @@ describe('useAttackAssigneesContextMenuItems', () => { expect(mockUseBulkAttackAssigneesItems).toHaveBeenCalledWith({ onAssigneesUpdate: mockOnSuccess, alertAssignments: ['user1', 'user2'], + telemetrySource: 'attacks_page_group_take_action', }); }); @@ -128,6 +130,7 @@ describe('useAttackAssigneesContextMenuItems', () => { expect(mockUseBulkAttackAssigneesItems).toHaveBeenCalledWith({ onAssigneesUpdate: mockOnSuccess, alertAssignments: ['user1', 'user2', 'user3'], + telemetrySource: 'attacks_page_group_take_action', }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_assignees_context_menu_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_assignees_context_menu_items.tsx index ebaebe749d6b2..1543b7af3a62c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_assignees_context_menu_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_assignees_context_menu_items.tsx @@ -33,6 +33,7 @@ export const useAttackAssigneesContextMenuItems = ({ setIsLoading, onSuccess, refresh, + telemetrySource, }: UseAttackAssigneesContextMenuItemsProps): BulkAttackContextMenuItems => { // Get all unique assignees from all attacks for the bulk hook const allAssignees = useMemo(() => { @@ -46,6 +47,7 @@ export const useAttackAssigneesContextMenuItems = ({ const bulkActionItems = useBulkAttackAssigneesItems({ onAssigneesUpdate: onSuccess, alertAssignments: allAssignees, + telemetrySource, }); const alertItems = useMemo(() => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items.test.tsx index 424b54b79304c..9093c449c2fe4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items.test.tsx @@ -96,6 +96,28 @@ describe('useAttackCaseContextMenuItems', () => { }); }); + it('should pass telemetrySource to useBulkAttackCaseItems', () => { + renderHook(() => + useAttackCaseContextMenuItems({ + attacksWithCase: [ + { + attackId: 'attack-1', + relatedAlertIds: ['alert-1'], + markdownComment: 'markdown', + }, + ], + title: 'Attack title', + telemetrySource: 'attacks_page_group_take_action', + }) + ); + + expect(mockUseBulkAttackCaseItems).toHaveBeenCalledWith({ + title: 'Attack title', + closePopover: undefined, + telemetrySource: 'attacks_page_group_take_action', + }); + }); + it('should pass closePopover to useBulkAttackCaseItems', () => { renderHook(() => useAttackCaseContextMenuItems({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items.tsx index e0fbdfe72fa6d..23b8f0a09e022 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items.tsx @@ -6,12 +6,13 @@ */ import { useMemo } from 'react'; + import { useBulkAttackCaseItems } from '../bulk_action_items/use_bulk_attack_case_items'; +import { transformBulkActionsToContextMenuItems } from '../utils/transform_bulk_actions_to_context_menu_items'; import { ALERT_ATTACK_DISCOVERY_ALERT_IDS, ALERT_ATTACK_DISCOVERY_MARKDOWN_COMMENT, } from '../constants'; -import { transformBulkActionsToContextMenuItems } from '../utils/transform_bulk_actions_to_context_menu_items'; import type { AttackWithCase, BaseAttackContextMenuItemsProps, @@ -32,10 +33,12 @@ export const useAttackCaseContextMenuItems = ({ clearSelection, setIsLoading, refresh, + telemetrySource, }: UseAttackCaseContextMenuItemsProps): BulkAttackContextMenuItems => { const bulkActionItems = useBulkAttackCaseItems({ title, closePopover, + telemetrySource, }); const alertItems = useMemo( diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items.test.tsx index 18c79837f3ed9..1b86682ed223f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items.test.tsx @@ -81,6 +81,20 @@ describe('useAttackInvestigateInTimelineContextMenuItems', () => { }); }); + it('should pass telemetrySource to useBulkAttackInvestigateInTimelineItems', () => { + renderHook(() => + useAttackInvestigateInTimelineContextMenuItems({ + attacksWithTimelineAlerts: [{ attackId: 'attack-1', relatedAlertIds: ['alert-1'] }], + telemetrySource: 'attacks_page_group_take_action', + }) + ); + + expect(mockUseBulkAttackInvestigateInTimelineItems).toHaveBeenCalledWith({ + closePopover: undefined, + telemetrySource: 'attacks_page_group_take_action', + }); + }); + it('should return empty panels', () => { const { result } = renderHook(() => useAttackInvestigateInTimelineContextMenuItems({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items.tsx index 115dd37e92d8d..6e411e6fe66bc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items.tsx @@ -27,8 +27,12 @@ export const useAttackInvestigateInTimelineContextMenuItems = ({ clearSelection, setIsLoading, refresh, + telemetrySource, }: UseAttackInvestigateInTimelineContextMenuItemsProps): BulkAttackContextMenuItems => { - const bulkActionItems = useBulkAttackInvestigateInTimelineItems({ closePopover }); + const bulkActionItems = useBulkAttackInvestigateInTimelineItems({ + closePopover, + telemetrySource, + }); const alertItems = useMemo( () => diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_tags_context_menu_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_tags_context_menu_items.test.tsx index f71a43d1f182f..3afc0438bfb63 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_tags_context_menu_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_tags_context_menu_items.test.tsx @@ -95,6 +95,22 @@ describe('useAttackTagsContextMenuItems', () => { }); }); + it('should pass telemetrySource to useBulkAttackTagsItems', () => { + renderHook( + () => + useAttackTagsContextMenuItems({ + ...defaultProps, + telemetrySource: 'attacks_page_group_take_action', + }), + { wrapper } + ); + + expect(mockUseBulkAttackTagsItems).toHaveBeenCalledWith({ + onTagsUpdate: mockOnSuccess, + telemetrySource: 'attacks_page_group_take_action', + }); + }); + it('should pass correct props to panel renderContent', () => { const mockRenderContent = jest.fn((props) => React.createElement('div', null, 'Tags Panel')); mockUseBulkAttackTagsItems.mockReturnValue({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_tags_context_menu_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_tags_context_menu_items.tsx index b641cc3cdf850..d819b29f09762 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_tags_context_menu_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_tags_context_menu_items.tsx @@ -33,9 +33,11 @@ export const useAttackTagsContextMenuItems = ({ setIsLoading, onSuccess, refresh, + telemetrySource, }: UseAttackTagsContextMenuItemsProps): BulkAttackContextMenuItems => { const bulkActionItems = useBulkAttackTagsItems({ onTagsUpdate: onSuccess, + telemetrySource, }); const alertItems = useMemo(() => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.test.tsx index 8d19c6b00089f..f60ad47c8d2ec 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.test.tsx @@ -14,6 +14,8 @@ import { useAgentBuilderAvailability } from '../../../../../agent_builder/hooks/ import { useReportAddToChat } from '../../../../../agent_builder/hooks/use_report_add_to_chat'; import { useAssistantAvailability } from '../../../../../assistant/use_assistant_availability'; import { useAttackViewInAiAssistantContextMenuItems } from './use_attack_view_in_ai_assistant_context_menu_items'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; jest.mock('@kbn/elastic-assistant'); jest.mock('@kbn/elastic-assistant-common', () => { @@ -28,6 +30,7 @@ jest.mock('../../../../../attack_discovery/pages/results/use_attack_discovery_at jest.mock('../../../../../agent_builder/hooks/use_agent_builder_availability'); jest.mock('../../../../../agent_builder/hooks/use_report_add_to_chat'); jest.mock('../../../../../assistant/use_assistant_availability'); +jest.mock('../../../../../common/lib/kibana'); const mockUseAttackDiscoveryAttachment = useAttackDiscoveryAttachment as jest.MockedFunction< typeof useAttackDiscoveryAttachment @@ -50,10 +53,20 @@ const mockAttack = getMockAttackDiscoveryAlerts()[0]; const mockRegisterPromptContext = jest.fn(); const mockUnRegisterPromptContext = jest.fn(); const mockShowAssistantOverlay = jest.fn(); +const reportEventMock = jest.fn(); describe('useAttackViewInAiAssistantContextMenuItems', () => { beforeEach(() => { jest.clearAllMocks(); + reportEventMock.mockClear(); + + (useKibana as jest.Mock).mockReturnValue({ + services: { + telemetry: { + reportEvent: reportEventMock, + }, + }, + }); mockUseAttackDiscoveryAttachment.mockReturnValue(jest.fn()); mockUseAgentBuilderAvailability.mockReturnValue({ @@ -124,6 +137,21 @@ describe('useAttackViewInAiAssistantContextMenuItems', () => { ); }); + it('should report AIAssistantOpened event on "View in AI Assistant" click', () => { + const { result } = renderHook(() => + useAttackViewInAiAssistantContextMenuItems({ + attack: mockAttack, + telemetrySource: 'attacks_page_group_take_action', + }) + ); + + result.current.items[0]?.onClick?.({} as React.MouseEvent); + + expect(reportEventMock).toHaveBeenCalledWith(AttacksEventTypes.AIAssistantOpened, { + source: 'attacks_page_group_take_action', + }); + }); + it('should return "Add to chat" when Agent Builder chat experience is enabled and user has privilege', () => { mockUseAgentBuilderAvailability.mockReturnValue({ hasAgentBuilderPrivilege: true, @@ -219,6 +247,28 @@ describe('useAttackViewInAiAssistantContextMenuItems', () => { ); }); + it('should not report AIAssistantOpened event on "Add to chat" click', () => { + const reportAddToChatClick = jest.fn(); + mockUseReportAddToChat.mockReturnValue(reportAddToChatClick); + mockUseAgentBuilderAvailability.mockReturnValue({ + hasAgentBuilderPrivilege: true, + isAgentChatExperienceEnabled: true, + hasValidAgentBuilderLicense: true, + isAgentBuilderEnabled: true, + }); + + const { result } = renderHook(() => + useAttackViewInAiAssistantContextMenuItems({ + attack: mockAttack, + telemetrySource: 'attacks_page_group_take_action', + }) + ); + + result.current.items[0]?.onClick?.({} as React.MouseEvent); + + expect(reportEventMock).not.toHaveBeenCalled(); + }); + it('should disable "View in AI Assistant" when user has no assistant privilege', () => { mockUseAssistantAvailability.mockReturnValue({ hasAssistantPrivilege: false, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.tsx index 5a88509caae63..d61ee6777a89f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.tsx @@ -12,32 +12,38 @@ import { type AttackDiscoveryAlert, } from '@kbn/elastic-assistant-common'; import type { EuiContextMenuPanelItemDescriptorEntry } from '@elastic/eui/src/components/context_menu/context_menu'; + import { useReportAddToChat } from '../../../../../agent_builder/hooks/use_report_add_to_chat'; import { useAgentBuilderAvailability } from '../../../../../agent_builder/hooks/use_agent_builder_availability'; import { useAssistantAvailability } from '../../../../../assistant/use_assistant_availability'; import { useAttackDiscoveryAttachment } from '../../../../../attack_discovery/pages/results/use_attack_discovery_attachment'; import * as i18n from '../../../../../attack_discovery/pages/results/take_action/translations'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { AttacksEventTypes } from '../../../../../common/lib/telemetry'; +import type { AttacksActionTelemetrySource } from '../../../../../common/lib/telemetry'; export interface UseAttackViewInAiAssistantContextMenuItemsProps { - /** - * The attack discovery object - */ + /** The attack discovery object */ attack: AttackDiscoveryAlert; - /** - * Optional callback to close the containing popover menu - */ + /** Optional callback to close the containing popover menu */ closePopover?: () => void; + /** Source of the action for telemetry */ + telemetrySource?: AttacksActionTelemetrySource; } export const useAttackViewInAiAssistantContextMenuItems = ({ attack, closePopover, + telemetrySource, }: UseAttackViewInAiAssistantContextMenuItemsProps): { items: EuiContextMenuPanelItemDescriptorEntry[]; } => { const { hasAssistantPrivilege } = useAssistantAvailability(); const { registerPromptContext, showAssistantOverlay, unRegisterPromptContext } = useAssistantContext(); + const { + services: { telemetry }, + } = useKibana(); const promptContextId = attack.id ?? null; const viewInAiAssistantDisabled = !hasAssistantPrivilege || promptContextId == null; @@ -50,6 +56,10 @@ export const useAttackViewInAiAssistantContextMenuItems = ({ const lastFive = attack.id ? ` - ${attack.id.slice(-5)}` : ''; const conversationTitle = `${attack.title ?? ''}${lastFive}`; + if (telemetrySource) { + telemetry.reportEvent(AttacksEventTypes.AIAssistantOpened, { source: telemetrySource }); + } + unRegisterPromptContext(promptContextId); registerPromptContext({ category: 'insight', @@ -75,6 +85,8 @@ export const useAttackViewInAiAssistantContextMenuItems = ({ registerPromptContext, showAssistantOverlay, unRegisterPromptContext, + telemetrySource, + telemetry, ]); const { hasAgentBuilderPrivilege, isAgentChatExperienceEnabled, hasValidAgentBuilderLicense } = @@ -87,6 +99,7 @@ export const useAttackViewInAiAssistantContextMenuItems = ({ pathway: 'attacks_page_group_take_action', attachments: ['alert'], }); + openAgentBuilderFlyout(); }, [openAgentBuilderFlyout, reportAddToChatClick]); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_workflow_status_context_menu_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_workflow_status_context_menu_items.test.tsx index 124d7b595832a..e9fc0b5e55fbd 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_workflow_status_context_menu_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_workflow_status_context_menu_items.test.tsx @@ -44,6 +44,7 @@ describe('useAttackWorkflowStatusContextMenuItems', () => { closePopover: mockClosePopover, setIsLoading: mockSetIsLoading, onSuccess: mockOnSuccess, + telemetrySource: 'attacks_page_group_take_action' as const, }; beforeEach(() => { @@ -94,6 +95,7 @@ describe('useAttackWorkflowStatusContextMenuItems', () => { expect(mockUseBulkAttackWorkflowStatusItems).toHaveBeenCalledWith({ onWorkflowStatusUpdate: mockOnSuccess, currentStatus: 'open', + telemetrySource: 'attacks_page_group_take_action', }); }); @@ -119,6 +121,7 @@ describe('useAttackWorkflowStatusContextMenuItems', () => { expect(mockUseBulkAttackWorkflowStatusItems).toHaveBeenCalledWith({ onWorkflowStatusUpdate: mockOnSuccess, currentStatus: 'open', + telemetrySource: 'attacks_page_group_take_action', }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_workflow_status_context_menu_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_workflow_status_context_menu_items.tsx index feedaf485ebad..aec96c83d7a30 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_workflow_status_context_menu_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_workflow_status_context_menu_items.tsx @@ -8,8 +8,8 @@ import { useMemo } from 'react'; import { useBulkAttackWorkflowStatusItems } from '../bulk_action_items/use_bulk_attack_workflow_status_items'; -import { ALERT_ATTACK_DISCOVERY_ALERT_IDS } from '../constants'; import { transformBulkActionsToContextMenuItems } from '../utils/transform_bulk_actions_to_context_menu_items'; +import { ALERT_ATTACK_DISCOVERY_ALERT_IDS } from '../constants'; import type { BaseAttackContextMenuItemsProps, AttackWithWorkflowStatus, @@ -34,6 +34,7 @@ export const useAttackWorkflowStatusContextMenuItems = ({ setIsLoading, onSuccess, refresh, + telemetrySource, }: UseAttackWorkflowStatusContextMenuItemsProps): BulkAttackContextMenuItems => { // Use the workflow status of the first attack in the array const currentStatus = useMemo(() => { @@ -43,6 +44,7 @@ export const useAttackWorkflowStatusContextMenuItems = ({ const bulkActionItems = useBulkAttackWorkflowStatusItems({ onWorkflowStatusUpdate: onSuccess, currentStatus, + telemetrySource, }); const alertItems = useMemo(() => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/types.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/types.ts index 03666dd8da328..8812b1178669a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/types.ts @@ -8,7 +8,9 @@ import type { BulkActionsConfig, ContentPanelConfig } from '@kbn/response-ops-alerts-table/types'; import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; import type { EuiContextMenuPanelItemDescriptorEntry } from '@elastic/eui/src/components/context_menu/context_menu'; + import type { AlertWorkflowStatus } from '../../../../common/types'; +import type { AttacksActionTelemetrySource } from '../../../../common/lib/telemetry'; /** * Base props shared by all apply attack hooks. @@ -23,6 +25,8 @@ export interface BaseApplyAttackProps { setIsLoading?: (loading: boolean) => void; /** Optional callback when operation succeeds */ onSuccess?: () => void; + /** Source of the action for telemetry */ + telemetrySource?: AttacksActionTelemetrySource; } /** @@ -40,6 +44,8 @@ export interface BaseAttackContextMenuItemsProps { refresh?: () => void; /** Optional callback to set loading state */ setIsLoading?: (loading: boolean) => void; + /** Source of the action for telemetry */ + telemetrySource?: AttacksActionTelemetrySource; } /** diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/attack_details/components/assignees.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/attack_details/components/assignees.test.tsx index 68a299ead0aab..e189cf9b3ba70 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/attack_details/components/assignees.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/attack_details/components/assignees.test.tsx @@ -133,6 +133,7 @@ describe('Assignees', () => { assignees: ['uid-1'], }, ], + telemetrySource: 'attacks_page_flyout_header', }) ); const call = mockUseAttackAssigneesContextMenuItems.mock.calls[0][0]; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/attack_details/components/assignees.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/attack_details/components/assignees.tsx index 0a2d5239995b4..b58da31c88145 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/attack_details/components/assignees.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/attack_details/components/assignees.tsx @@ -81,6 +81,7 @@ export const Assignees = memo(() => { attacksWithAssignees, closePopover, onSuccess, + telemetrySource: 'attacks_page_flyout_header', }); const uids = useMemo(() => new Set(assignees), [assignees]); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/attack_details/components/status_popover_button.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/attack_details/components/status_popover_button.test.tsx index fda24df979fa8..69a42b31d6e98 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/attack_details/components/status_popover_button.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/attack_details/components/status_popover_button.test.tsx @@ -75,7 +75,7 @@ describe('StatusPopoverButton (attack details)', () => { jest.clearAllMocks(); (useAttackWorkflowStatusContextMenuItems as jest.Mock).mockImplementation( - ({ onSuccess }: { onSuccess: () => void }) => ({ + ({ onSuccess, telemetrySource }: { onSuccess: () => void; telemetrySource?: string }) => ({ items: [ { name: 'Mark as acknowledged', @@ -91,6 +91,20 @@ describe('StatusPopoverButton (attack details)', () => { ); }); + test('it passes the correct telemetry source', () => { + render( + + + + ); + + expect(useAttackWorkflowStatusContextMenuItems).toHaveBeenCalledWith( + expect.objectContaining({ + telemetrySource: 'attacks_page_flyout_header', + }) + ); + }); + test('it renders the current status', () => { render( diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/attack_details/components/status_popover_button.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/attack_details/components/status_popover_button.tsx index 2b50edbb45249..5cd9ca4c90aa8 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/attack_details/components/status_popover_button.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/attack_details/components/status_popover_button.tsx @@ -77,6 +77,7 @@ export const StatusPopoverButton = memo(({ enrichedFieldInfo }: StatusPopoverBut ], closePopover: togglePopover, onSuccess: onWorkflowStatusChange, + telemetrySource: 'attacks_page_flyout_header', }); const button = useMemo(