mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Security Solution] AI Assistant telemetry (#162653)
This commit is contained in:
parent
1857f7339d
commit
d829927dbe
31 changed files with 848 additions and 52 deletions
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import { AssistantOverlay } from '.';
|
||||
import { TestProviders } from '../../mock/test_providers/test_providers';
|
||||
|
||||
const reportAssistantInvoked = jest.fn();
|
||||
const assistantTelemetry = {
|
||||
reportAssistantInvoked,
|
||||
reportAssistantMessageSent: () => {},
|
||||
reportAssistantQuickPrompt: () => {},
|
||||
};
|
||||
describe('AssistantOverlay', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('renders when isAssistantEnabled prop is true and keyboard shortcut is pressed', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders providerContext={{ assistantTelemetry }}>
|
||||
<AssistantOverlay isAssistantEnabled={true} />
|
||||
</TestProviders>
|
||||
);
|
||||
fireEvent.keyDown(document, { key: ';', ctrlKey: true });
|
||||
const modal = getByTestId('ai-assistant-modal');
|
||||
expect(modal).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('modal closes when close button is clicked', () => {
|
||||
const { getByLabelText, queryByTestId } = render(
|
||||
<TestProviders>
|
||||
<AssistantOverlay isAssistantEnabled={true} />
|
||||
</TestProviders>
|
||||
);
|
||||
fireEvent.keyDown(document, { key: ';', ctrlKey: true });
|
||||
const closeButton = getByLabelText('Closes this modal window');
|
||||
fireEvent.click(closeButton);
|
||||
const modal = queryByTestId('ai-assistant-modal');
|
||||
expect(modal).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Assistant invoked from shortcut tracking happens on modal open only (not close)', () => {
|
||||
render(
|
||||
<TestProviders providerContext={{ assistantTelemetry }}>
|
||||
<AssistantOverlay isAssistantEnabled={true} />
|
||||
</TestProviders>
|
||||
);
|
||||
fireEvent.keyDown(document, { key: ';', ctrlKey: true });
|
||||
expect(reportAssistantInvoked).toHaveBeenCalledTimes(1);
|
||||
expect(reportAssistantInvoked).toHaveBeenCalledWith({
|
||||
invokedBy: 'shortcut',
|
||||
conversationId: 'Welcome',
|
||||
});
|
||||
fireEvent.keyDown(document, { key: ';', ctrlKey: true });
|
||||
expect(reportAssistantInvoked).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('modal closes when shortcut is pressed and modal is already open', () => {
|
||||
const { queryByTestId } = render(
|
||||
<TestProviders>
|
||||
<AssistantOverlay isAssistantEnabled={true} />
|
||||
</TestProviders>
|
||||
);
|
||||
fireEvent.keyDown(document, { key: ';', ctrlKey: true });
|
||||
fireEvent.keyDown(document, { key: ';', ctrlKey: true });
|
||||
const modal = queryByTestId('ai-assistant-modal');
|
||||
expect(modal).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('modal does not open when incorrect shortcut is pressed', () => {
|
||||
const { queryByTestId } = render(
|
||||
<TestProviders>
|
||||
<AssistantOverlay isAssistantEnabled={true} />
|
||||
</TestProviders>
|
||||
);
|
||||
fireEvent.keyDown(document, { key: 'a', ctrlKey: true });
|
||||
const modal = queryByTestId('ai-assistant-modal');
|
||||
expect(modal).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -36,7 +36,8 @@ export const AssistantOverlay = React.memo<Props>(({ isAssistantEnabled }) => {
|
|||
WELCOME_CONVERSATION_TITLE
|
||||
);
|
||||
const [promptContextId, setPromptContextId] = useState<string | undefined>();
|
||||
const { setShowAssistantOverlay, localStorageLastConversationId } = useAssistantContext();
|
||||
const { assistantTelemetry, setShowAssistantOverlay, localStorageLastConversationId } =
|
||||
useAssistantContext();
|
||||
|
||||
// Bind `showAssistantOverlay` in SecurityAssistantContext to this modal instance
|
||||
const showOverlay = useCallback(
|
||||
|
@ -46,11 +47,16 @@ export const AssistantOverlay = React.memo<Props>(({ isAssistantEnabled }) => {
|
|||
promptContextId: pid,
|
||||
conversationId: cid,
|
||||
}: ShowAssistantOverlayProps) => {
|
||||
if (so)
|
||||
assistantTelemetry?.reportAssistantInvoked({
|
||||
conversationId: cid ?? 'unknown',
|
||||
invokedBy: 'click',
|
||||
});
|
||||
setIsModalVisible(so);
|
||||
setPromptContextId(pid);
|
||||
setConversationId(cid);
|
||||
},
|
||||
[setIsModalVisible]
|
||||
[assistantTelemetry]
|
||||
);
|
||||
useEffect(() => {
|
||||
setShowAssistantOverlay(showOverlay);
|
||||
|
@ -61,10 +67,14 @@ export const AssistantOverlay = React.memo<Props>(({ isAssistantEnabled }) => {
|
|||
// Try to restore the last conversation on shortcut pressed
|
||||
if (!isModalVisible) {
|
||||
setConversationId(localStorageLastConversationId ?? WELCOME_CONVERSATION_TITLE);
|
||||
assistantTelemetry?.reportAssistantInvoked({
|
||||
invokedBy: 'shortcut',
|
||||
conversationId: localStorageLastConversationId ?? WELCOME_CONVERSATION_TITLE,
|
||||
});
|
||||
}
|
||||
|
||||
setIsModalVisible(!isModalVisible);
|
||||
}, [isModalVisible, localStorageLastConversationId]);
|
||||
}, [assistantTelemetry, isModalVisible, localStorageLastConversationId]);
|
||||
|
||||
// Register keyboard listener to show the modal when cmd + ; is pressed
|
||||
const onKeyDown = useCallback(
|
||||
|
|
|
@ -46,10 +46,10 @@ const getInitialConversations = (): Record<string, Conversation> => ({
|
|||
},
|
||||
});
|
||||
|
||||
const renderAssistant = () =>
|
||||
const renderAssistant = (extraProps = {}) =>
|
||||
render(
|
||||
<TestProviders getInitialConversations={getInitialConversations}>
|
||||
<Assistant isAssistantEnabled />
|
||||
<Assistant isAssistantEnabled {...extraProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -143,6 +143,22 @@ describe('Assistant', () => {
|
|||
});
|
||||
expect(persistToLocalStorage).toHaveBeenLastCalledWith(WELCOME_CONVERSATION_TITLE);
|
||||
});
|
||||
it('should call the setConversationId callback if it is defined and the conversation id changes', async () => {
|
||||
const connectors: unknown[] = [{}];
|
||||
const setConversationId = jest.fn();
|
||||
jest.mocked(useLoadConnectors).mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: connectors,
|
||||
} as unknown as UseQueryResult<ActionConnector[], IHttpFetchError>);
|
||||
|
||||
renderAssistant({ setConversationId });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Previous conversation'));
|
||||
});
|
||||
|
||||
expect(setConversationId).toHaveBeenLastCalledWith('electric sheep');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when no connectors are loaded', () => {
|
||||
|
|
|
@ -5,7 +5,16 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
|
@ -46,6 +55,7 @@ export interface Props {
|
|||
promptContextId?: string;
|
||||
shouldRefocusPrompt?: boolean;
|
||||
showTitle?: boolean;
|
||||
setConversationId?: Dispatch<SetStateAction<string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -58,9 +68,11 @@ const AssistantComponent: React.FC<Props> = ({
|
|||
promptContextId = '',
|
||||
shouldRefocusPrompt = false,
|
||||
showTitle = true,
|
||||
setConversationId,
|
||||
}) => {
|
||||
const {
|
||||
actionTypeRegistry,
|
||||
assistantTelemetry,
|
||||
augmentMessageCodeBlocks,
|
||||
conversations,
|
||||
defaultAllow,
|
||||
|
@ -112,6 +124,12 @@ const AssistantComponent: React.FC<Props> = ({
|
|||
: WELCOME_CONVERSATION_TITLE
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (setConversationId) {
|
||||
setConversationId(selectedConversationId);
|
||||
}
|
||||
}, [selectedConversationId, setConversationId]);
|
||||
|
||||
const currentConversation = useMemo(
|
||||
() =>
|
||||
conversations[selectedConversationId] ??
|
||||
|
@ -396,6 +414,16 @@ const AssistantComponent: React.FC<Props> = ({
|
|||
return chatbotComments;
|
||||
}, [connectorComments, isDisabled, chatbotComments]);
|
||||
|
||||
const trackPrompt = useCallback(
|
||||
(promptTitle: string) => {
|
||||
assistantTelemetry?.reportAssistantQuickPrompt({
|
||||
conversationId: selectedConversationId,
|
||||
promptTitle,
|
||||
});
|
||||
},
|
||||
[assistantTelemetry, selectedConversationId]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiModalHeader
|
||||
|
@ -485,6 +513,7 @@ const AssistantComponent: React.FC<Props> = ({
|
|||
<QuickPrompts
|
||||
setInput={setUserPrompt}
|
||||
setIsSettingsModalVisible={setIsSettingsModalVisible}
|
||||
trackPrompt={trackPrompt}
|
||||
/>
|
||||
)}
|
||||
</EuiModalFooter>
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import { QuickPrompts } from './quick_prompts';
|
||||
import { TestProviders } from '../../mock/test_providers/test_providers';
|
||||
import { MOCK_QUICK_PROMPTS } from '../../mock/quick_prompt';
|
||||
import { QUICK_PROMPTS_TAB } from '../settings/assistant_settings';
|
||||
|
||||
const setInput = jest.fn();
|
||||
const setIsSettingsModalVisible = jest.fn();
|
||||
const trackPrompt = jest.fn();
|
||||
const testProps = {
|
||||
setInput,
|
||||
setIsSettingsModalVisible,
|
||||
trackPrompt,
|
||||
};
|
||||
const setSelectedSettingsTab = jest.fn();
|
||||
const mockUseAssistantContext = {
|
||||
setSelectedSettingsTab,
|
||||
promptContexts: {},
|
||||
allQuickPrompts: MOCK_QUICK_PROMPTS,
|
||||
};
|
||||
|
||||
const testTitle = 'SPL_QUERY_CONVERSION_TITLE';
|
||||
const testPrompt = 'SPL_QUERY_CONVERSION_PROMPT';
|
||||
const customTitle = 'A_CUSTOM_OPTION';
|
||||
|
||||
jest.mock('../../assistant_context', () => ({
|
||||
...jest.requireActual('../../assistant_context'),
|
||||
useAssistantContext: () => mockUseAssistantContext,
|
||||
}));
|
||||
|
||||
describe('QuickPrompts', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('onClickAddQuickPrompt calls setInput with the prompt, and trackPrompt with the prompt title', () => {
|
||||
const { getByText } = render(
|
||||
<TestProviders>
|
||||
<QuickPrompts {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
fireEvent.click(getByText(testTitle));
|
||||
|
||||
expect(setInput).toHaveBeenCalledWith(testPrompt);
|
||||
expect(trackPrompt).toHaveBeenCalledWith(testTitle);
|
||||
});
|
||||
it('onClickAddQuickPrompt calls trackPrompt with "Custom" when isDefault=false prompt is chosen', () => {
|
||||
const { getByText } = render(
|
||||
<TestProviders>
|
||||
<QuickPrompts {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
fireEvent.click(getByText(customTitle));
|
||||
|
||||
expect(trackPrompt).toHaveBeenCalledWith('Custom');
|
||||
});
|
||||
|
||||
it('clicking "Add quick prompt" button opens the settings modal', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<QuickPrompts {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
fireEvent.click(getByTestId('addQuickPrompt'));
|
||||
expect(setIsSettingsModalVisible).toHaveBeenCalledWith(true);
|
||||
expect(setSelectedSettingsTab).toHaveBeenCalledWith(QUICK_PROMPTS_TAB);
|
||||
});
|
||||
});
|
|
@ -10,6 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiPopover, EuiButtonEmpty } from
|
|||
// eslint-disable-next-line @kbn/eslint/module_migration
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { QuickPrompt } from '../../..';
|
||||
import * as i18n from './translations';
|
||||
import { useAssistantContext } from '../../assistant_context';
|
||||
import { QUICK_PROMPTS_TAB } from '../settings/assistant_settings';
|
||||
|
@ -22,6 +23,7 @@ const COUNT_BEFORE_OVERFLOW = 5;
|
|||
interface QuickPromptsProps {
|
||||
setInput: (input: string) => void;
|
||||
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
trackPrompt: (prompt: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -30,7 +32,7 @@ interface QuickPromptsProps {
|
|||
* and localstorage for storing new and edited prompts.
|
||||
*/
|
||||
export const QuickPrompts: React.FC<QuickPromptsProps> = React.memo(
|
||||
({ setInput, setIsSettingsModalVisible }) => {
|
||||
({ setInput, setIsSettingsModalVisible, trackPrompt }) => {
|
||||
const { allQuickPrompts, promptContexts, setSelectedSettingsTab } = useAssistantContext();
|
||||
|
||||
const contextFilteredQuickPrompts = useMemo(() => {
|
||||
|
@ -55,12 +57,24 @@ export const QuickPrompts: React.FC<QuickPromptsProps> = React.memo(
|
|||
);
|
||||
const closeOverflowPopover = useCallback(() => setIsOverflowPopoverOpen(false), []);
|
||||
|
||||
const onClickAddQuickPrompt = useCallback(
|
||||
(badge: QuickPrompt) => {
|
||||
setInput(badge.prompt);
|
||||
if (badge.isDefault) {
|
||||
trackPrompt(badge.title);
|
||||
} else {
|
||||
trackPrompt('Custom');
|
||||
}
|
||||
},
|
||||
[setInput, trackPrompt]
|
||||
);
|
||||
|
||||
const onClickOverflowQuickPrompt = useCallback(
|
||||
(prompt: string) => {
|
||||
setInput(prompt);
|
||||
(badge: QuickPrompt) => {
|
||||
onClickAddQuickPrompt(badge);
|
||||
closeOverflowPopover();
|
||||
},
|
||||
[closeOverflowPopover, setInput]
|
||||
[closeOverflowPopover, onClickAddQuickPrompt]
|
||||
);
|
||||
|
||||
const showQuickPromptSettings = useCallback(() => {
|
||||
|
@ -74,7 +88,7 @@ export const QuickPrompts: React.FC<QuickPromptsProps> = React.memo(
|
|||
<EuiFlexItem key={index} grow={false}>
|
||||
<EuiBadge
|
||||
color={badge.color}
|
||||
onClick={() => setInput(badge.prompt)}
|
||||
onClick={() => onClickAddQuickPrompt(badge)}
|
||||
onClickAriaLabel={badge.title}
|
||||
>
|
||||
{badge.title}
|
||||
|
@ -101,7 +115,7 @@ export const QuickPrompts: React.FC<QuickPromptsProps> = React.memo(
|
|||
<EuiFlexItem key={index} grow={false}>
|
||||
<EuiBadge
|
||||
color={badge.color}
|
||||
onClick={() => onClickOverflowQuickPrompt(badge.prompt)}
|
||||
onClick={() => onClickOverflowQuickPrompt(badge)}
|
||||
onClickAriaLabel={badge.title}
|
||||
>
|
||||
{badge.title}
|
||||
|
@ -113,7 +127,12 @@ export const QuickPrompts: React.FC<QuickPromptsProps> = React.memo(
|
|||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty onClick={showQuickPromptSettings} iconType="plus" size="xs">
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="addQuickPrompt"
|
||||
onClick={showQuickPromptSettings}
|
||||
iconType="plus"
|
||||
size="xs"
|
||||
>
|
||||
{i18n.ADD_QUICK_PROMPT}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -69,13 +69,14 @@ interface UseConversation {
|
|||
}
|
||||
|
||||
export const useConversation = (): UseConversation => {
|
||||
const { allSystemPrompts, setConversations } = useAssistantContext();
|
||||
const { allSystemPrompts, assistantTelemetry, setConversations } = useAssistantContext();
|
||||
|
||||
/**
|
||||
* Append a message to the conversation[] for a given conversationId
|
||||
*/
|
||||
const appendMessage = useCallback(
|
||||
({ conversationId, message }: AppendMessageProps): Message[] => {
|
||||
assistantTelemetry?.reportAssistantMessageSent({ conversationId, role: message.role });
|
||||
let messages: Message[] = [];
|
||||
setConversations((prev: Record<string, Conversation>) => {
|
||||
const prevConversation: Conversation | undefined = prev[conversationId];
|
||||
|
@ -86,7 +87,6 @@ export const useConversation = (): UseConversation => {
|
|||
...prevConversation,
|
||||
messages,
|
||||
};
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[conversationId]: newConversation,
|
||||
|
@ -97,7 +97,7 @@ export const useConversation = (): UseConversation => {
|
|||
});
|
||||
return messages;
|
||||
},
|
||||
[setConversations]
|
||||
[assistantTelemetry, setConversations]
|
||||
);
|
||||
|
||||
const appendReplacements = useCallback(
|
||||
|
|
|
@ -33,6 +33,7 @@ import {
|
|||
SYSTEM_PROMPT_LOCAL_STORAGE_KEY,
|
||||
} from './constants';
|
||||
import { CONVERSATIONS_TAB, SettingsTabs } from '../assistant/settings/assistant_settings';
|
||||
import { AssistantTelemetry } from './types';
|
||||
|
||||
export interface ShowAssistantOverlayProps {
|
||||
showOverlay: boolean;
|
||||
|
@ -45,8 +46,9 @@ type ShowAssistantOverlay = ({
|
|||
promptContextId,
|
||||
conversationId,
|
||||
}: ShowAssistantOverlayProps) => void;
|
||||
interface AssistantProviderProps {
|
||||
export interface AssistantProviderProps {
|
||||
actionTypeRegistry: ActionTypeRegistryContract;
|
||||
assistantTelemetry?: AssistantTelemetry;
|
||||
augmentMessageCodeBlocks: (currentConversation: Conversation) => CodeBlockDetails[][];
|
||||
baseAllow: string[];
|
||||
baseAllowReplacement: string[];
|
||||
|
@ -77,6 +79,7 @@ interface AssistantProviderProps {
|
|||
|
||||
export interface UseAssistantContext {
|
||||
actionTypeRegistry: ActionTypeRegistryContract;
|
||||
assistantTelemetry?: AssistantTelemetry;
|
||||
augmentMessageCodeBlocks: (currentConversation: Conversation) => CodeBlockDetails[][];
|
||||
allQuickPrompts: QuickPrompt[];
|
||||
allSystemPrompts: Prompt[];
|
||||
|
@ -123,6 +126,7 @@ const AssistantContext = React.createContext<UseAssistantContext | undefined>(un
|
|||
|
||||
export const AssistantProvider: React.FC<AssistantProviderProps> = ({
|
||||
actionTypeRegistry,
|
||||
assistantTelemetry,
|
||||
augmentMessageCodeBlocks,
|
||||
baseAllow,
|
||||
baseAllowReplacement,
|
||||
|
@ -240,6 +244,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
|
|||
const value = useMemo(
|
||||
() => ({
|
||||
actionTypeRegistry,
|
||||
assistantTelemetry,
|
||||
augmentMessageCodeBlocks,
|
||||
allQuickPrompts: localStorageQuickPrompts ?? [],
|
||||
allSystemPrompts: localStorageSystemPrompts ?? [],
|
||||
|
@ -274,6 +279,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
|
|||
}),
|
||||
[
|
||||
actionTypeRegistry,
|
||||
assistantTelemetry,
|
||||
augmentMessageCodeBlocks,
|
||||
baseAllow,
|
||||
baseAllowReplacement,
|
||||
|
|
|
@ -56,3 +56,9 @@ export interface Conversation {
|
|||
isDefault?: boolean;
|
||||
excludeFromLastConversationStorage?: boolean;
|
||||
}
|
||||
|
||||
export interface AssistantTelemetry {
|
||||
reportAssistantInvoked: (params: { invokedBy: string; conversationId: string }) => void;
|
||||
reportAssistantMessageSent: (params: { conversationId: string; role: string }) => void;
|
||||
reportAssistantQuickPrompt: (params: { conversationId: string; promptTitle: string }) => void;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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 { QuickPrompt } from '../..';
|
||||
|
||||
export const MOCK_QUICK_PROMPTS: QuickPrompt[] = [
|
||||
{
|
||||
title: 'ALERT_SUMMARIZATION_TITLE',
|
||||
prompt: 'ALERT_SUMMARIZATION_PROMPT',
|
||||
color: '#F68FBE',
|
||||
categories: ['PROMPT_CONTEXT_ALERT_CATEGORY'],
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
title: 'RULE_CREATION_TITLE',
|
||||
prompt: 'RULE_CREATION_PROMPT',
|
||||
categories: ['PROMPT_CONTEXT_DETECTION_RULES_CATEGORY'],
|
||||
color: '#7DDED8',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
title: 'WORKFLOW_ANALYSIS_TITLE',
|
||||
prompt: 'WORKFLOW_ANALYSIS_PROMPT',
|
||||
color: '#36A2EF',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
title: 'THREAT_INVESTIGATION_GUIDES_TITLE',
|
||||
prompt: 'THREAT_INVESTIGATION_GUIDES_PROMPT',
|
||||
categories: ['PROMPT_CONTEXT_EVENT_CATEGORY'],
|
||||
color: '#F3D371',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
title: 'SPL_QUERY_CONVERSION_TITLE',
|
||||
prompt: 'SPL_QUERY_CONVERSION_PROMPT',
|
||||
color: '#BADA55',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
title: 'AUTOMATION_TITLE',
|
||||
prompt: 'AUTOMATION_PROMPT',
|
||||
color: '#FFA500',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
title: 'A_CUSTOM_OPTION',
|
||||
prompt: 'quickly prompt please',
|
||||
color: '#D36086',
|
||||
categories: [],
|
||||
},
|
||||
];
|
|
@ -4,7 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock';
|
||||
|
@ -13,12 +13,14 @@ import React from 'react';
|
|||
// eslint-disable-next-line @kbn/eslint/module_migration
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
|
||||
import { AssistantProvider } from '../../assistant_context';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AssistantProvider, AssistantProviderProps } from '../../assistant_context';
|
||||
import { Conversation } from '../../assistant_context/types';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
getInitialConversations?: () => Record<string, Conversation>;
|
||||
providerContext?: Partial<AssistantProviderProps>;
|
||||
}
|
||||
|
||||
window.scrollTo = jest.fn();
|
||||
|
@ -30,34 +32,50 @@ const mockGetInitialConversations = () => ({});
|
|||
export const TestProvidersComponent: React.FC<Props> = ({
|
||||
children,
|
||||
getInitialConversations = mockGetInitialConversations,
|
||||
providerContext,
|
||||
}) => {
|
||||
const actionTypeRegistry = actionTypeRegistryMock.create();
|
||||
const mockGetComments = jest.fn(() => []);
|
||||
const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' });
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<I18nProvider>
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<AssistantProvider
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
augmentMessageCodeBlocks={jest.fn().mockReturnValue([])}
|
||||
baseAllow={[]}
|
||||
baseAllowReplacement={[]}
|
||||
defaultAllow={[]}
|
||||
defaultAllowReplacement={[]}
|
||||
docLinks={{
|
||||
ELASTIC_WEBSITE_URL: 'https://www.elastic.co/',
|
||||
DOC_LINK_VERSION: 'current',
|
||||
}}
|
||||
getComments={mockGetComments}
|
||||
getInitialConversations={getInitialConversations}
|
||||
setConversations={jest.fn()}
|
||||
setDefaultAllow={jest.fn()}
|
||||
setDefaultAllowReplacement={jest.fn()}
|
||||
http={mockHttp}
|
||||
>
|
||||
{children}
|
||||
</AssistantProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AssistantProvider
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
augmentMessageCodeBlocks={jest.fn().mockReturnValue([])}
|
||||
baseAllow={[]}
|
||||
baseAllowReplacement={[]}
|
||||
defaultAllow={[]}
|
||||
defaultAllowReplacement={[]}
|
||||
docLinks={{
|
||||
ELASTIC_WEBSITE_URL: 'https://www.elastic.co/',
|
||||
DOC_LINK_VERSION: 'current',
|
||||
}}
|
||||
getComments={mockGetComments}
|
||||
getInitialConversations={getInitialConversations}
|
||||
setConversations={jest.fn()}
|
||||
setDefaultAllow={jest.fn()}
|
||||
setDefaultAllowReplacement={jest.fn()}
|
||||
http={mockHttp}
|
||||
{...providerContext}
|
||||
>
|
||||
{children}
|
||||
</AssistantProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
|
|
@ -90,7 +90,7 @@ export type {
|
|||
} from './impl/assistant/use_conversation/helpers';
|
||||
|
||||
/** serialized conversations */
|
||||
export type { Conversation, Message } from './impl/assistant_context/types';
|
||||
export type { AssistantTelemetry, Conversation, Message } from './impl/assistant_context/types';
|
||||
|
||||
/** Interface for defining system/user prompts */
|
||||
export type { Prompt } from './impl/assistant/types';
|
||||
|
|
|
@ -7,7 +7,12 @@
|
|||
|
||||
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
import { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
import { getExecutionsPerDayCount, getInUseTotalCount, getTotalCount } from './actions_telemetry';
|
||||
import {
|
||||
getCounts,
|
||||
getExecutionsPerDayCount,
|
||||
getInUseTotalCount,
|
||||
getTotalCount,
|
||||
} from './actions_telemetry';
|
||||
|
||||
const mockLogger = loggingSystemMock.create().get();
|
||||
|
||||
|
@ -111,6 +116,7 @@ describe('actions telemetry', () => {
|
|||
"another.type__": 1,
|
||||
"some.type": 1,
|
||||
},
|
||||
"countGenAiProviderTypes": Object {},
|
||||
"countTotal": 4,
|
||||
"hasErrors": false,
|
||||
}
|
||||
|
@ -130,6 +136,7 @@ describe('actions telemetry', () => {
|
|||
expect(telemetry).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"countByType": Object {},
|
||||
"countGenAiProviderTypes": Object {},
|
||||
"countTotal": 0,
|
||||
"errorMessage": "oh no",
|
||||
"hasErrors": true,
|
||||
|
@ -451,6 +458,7 @@ describe('actions telemetry', () => {
|
|||
"another.type__": 1,
|
||||
"some.type": 1,
|
||||
},
|
||||
"countGenAiProviderTypes": Object {},
|
||||
"countTotal": 6,
|
||||
"hasErrors": false,
|
||||
}
|
||||
|
@ -494,6 +502,7 @@ describe('actions telemetry', () => {
|
|||
"countByType": Object {
|
||||
"test.system-action": 1,
|
||||
},
|
||||
"countGenAiProviderTypes": Object {},
|
||||
"countTotal": 1,
|
||||
"hasErrors": false,
|
||||
}
|
||||
|
@ -957,4 +966,21 @@ describe('actions telemetry', () => {
|
|||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('getCounts', () => {
|
||||
const aggs = {
|
||||
'.d3security': 2,
|
||||
'.gen-ai__Azure OpenAI': 3,
|
||||
'.gen-ai__OpenAI': 1,
|
||||
};
|
||||
const { countByType, countGenAiProviderTypes } = getCounts(aggs);
|
||||
expect(countByType).toEqual({
|
||||
__d3security: 2,
|
||||
'__gen-ai': 4,
|
||||
});
|
||||
expect(countGenAiProviderTypes).toEqual({
|
||||
'Azure OpenAI': 3,
|
||||
OpenAI: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -31,7 +31,12 @@ export async function getTotalCount(
|
|||
init_script: 'state.types = [:]',
|
||||
map_script: `
|
||||
String actionType = doc['action.actionTypeId'].value;
|
||||
state.types.put(actionType, state.types.containsKey(actionType) ? state.types.get(actionType) + 1 : 1);
|
||||
if (actionType =~ /\.gen-ai/) {
|
||||
String genAiActionType = actionType +"__"+ doc['apiProvider'].value;
|
||||
state.types.put(genAiActionType, state.types.containsKey(genAiActionType) ? state.types.get(genAiActionType) + 1 : 1);
|
||||
} else {
|
||||
state.types.put(actionType, state.types.containsKey(actionType) ? state.types.get(actionType) + 1 : 1);
|
||||
}
|
||||
`,
|
||||
// Combine script is executed per cluster, but we already have a key-value pair per cluster.
|
||||
// Despite docs that say this is optional, this script can't be blank.
|
||||
|
@ -60,6 +65,19 @@ export async function getTotalCount(
|
|||
>({
|
||||
index: kibanaIndex,
|
||||
size: 0,
|
||||
runtime_mappings: {
|
||||
apiProvider: {
|
||||
type: 'keyword',
|
||||
script: {
|
||||
// add apiProvider to the doc so we can use it in the scripted_metric
|
||||
source: `
|
||||
if (doc['action.actionTypeId'].value =~ /\.gen-ai/) {
|
||||
emit(params._source["action"]["config"]["apiProvider"])
|
||||
}
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
|
@ -73,11 +91,7 @@ export async function getTotalCount(
|
|||
});
|
||||
|
||||
const aggs = searchResult.aggregations?.byActionTypeId.value?.types ?? {};
|
||||
|
||||
const countByType = Object.keys(aggs).reduce<Record<string, number>>((obj, key) => {
|
||||
obj[replaceFirstAndLastDotSymbols(key)] = aggs[key];
|
||||
return obj;
|
||||
}, {});
|
||||
const { countGenAiProviderTypes, countByType } = getCounts(aggs);
|
||||
|
||||
if (inMemoryConnectors && inMemoryConnectors.length) {
|
||||
for (const inMemoryConnector of inMemoryConnectors) {
|
||||
|
@ -95,6 +109,7 @@ export async function getTotalCount(
|
|||
hasErrors: false,
|
||||
countTotal: totals,
|
||||
countByType,
|
||||
countGenAiProviderTypes,
|
||||
};
|
||||
} catch (err) {
|
||||
const errorMessage = err && err.message ? err.message : err.toString();
|
||||
|
@ -106,6 +121,7 @@ export async function getTotalCount(
|
|||
errorMessage,
|
||||
countTotal: 0,
|
||||
countByType: {},
|
||||
countGenAiProviderTypes: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -456,6 +472,36 @@ export async function getInUseTotalCount(
|
|||
}
|
||||
}
|
||||
|
||||
export const getCounts = (aggs: Record<string, number>) => {
|
||||
const countGenAiProviderTypes: Record<string, number> = {};
|
||||
|
||||
const countByType = Object.keys(aggs).reduce<Record<string, number>>((obj, key) => {
|
||||
const genAiKey = '.gen-ai';
|
||||
if (key.includes(genAiKey)) {
|
||||
const newKey = replaceFirstAndLastDotSymbols(genAiKey);
|
||||
if (obj[newKey] != null) {
|
||||
obj[newKey] = obj[newKey] + aggs[key];
|
||||
} else {
|
||||
obj[newKey] = aggs[key];
|
||||
}
|
||||
const genAiProvder = key.split(`${genAiKey}__`)[1];
|
||||
if (countGenAiProviderTypes[genAiProvder] != null) {
|
||||
countGenAiProviderTypes[genAiProvder] = obj[genAiProvder] + aggs[key];
|
||||
} else {
|
||||
countGenAiProviderTypes[genAiProvder] = aggs[key];
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
obj[replaceFirstAndLastDotSymbols(key)] = aggs[key];
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
countByType,
|
||||
countGenAiProviderTypes,
|
||||
};
|
||||
};
|
||||
|
||||
export function replaceFirstAndLastDotSymbols(strToReplace: string) {
|
||||
const hasFirstSymbolDot = strToReplace.startsWith('.');
|
||||
const appliedString = hasFirstSymbolDot ? strToReplace.replace('.', '__') : strToReplace;
|
||||
|
|
|
@ -8,7 +8,12 @@
|
|||
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
|
||||
import { get } from 'lodash';
|
||||
import { TaskManagerStartContract } from '@kbn/task-manager-plugin/server';
|
||||
import { ActionsUsage, byServiceProviderTypeSchema, byTypeSchema } from './types';
|
||||
import {
|
||||
ActionsUsage,
|
||||
byGenAiProviderTypeSchema,
|
||||
byServiceProviderTypeSchema,
|
||||
byTypeSchema,
|
||||
} from './types';
|
||||
import { ActionsConfig } from '../config';
|
||||
|
||||
export function createActionsUsageCollector(
|
||||
|
@ -31,6 +36,7 @@ export function createActionsUsageCollector(
|
|||
},
|
||||
count_total: { type: 'long' },
|
||||
count_by_type: byTypeSchema,
|
||||
count_gen_ai_provider_types: byGenAiProviderTypeSchema,
|
||||
count_active_total: { type: 'long' },
|
||||
count_active_alert_history_connectors: {
|
||||
type: 'long',
|
||||
|
@ -73,6 +79,7 @@ export function createActionsUsageCollector(
|
|||
alert_history_connector_enabled: false,
|
||||
count_total: 0,
|
||||
count_by_type: {},
|
||||
count_gen_ai_provider_types: {},
|
||||
count_active_total: 0,
|
||||
count_active_alert_history_connectors: 0,
|
||||
count_active_by_type: {},
|
||||
|
|
|
@ -112,6 +112,7 @@ export function telemetryTaskRunner(
|
|||
runs: (state.runs || 0) + 1,
|
||||
count_total: totalAggegations.countTotal,
|
||||
count_by_type: totalAggegations.countByType,
|
||||
count_gen_ai_provider_types: totalAggegations.countGenAiProviderTypes,
|
||||
count_active_total: totalInUse.countTotal,
|
||||
count_active_by_type: totalInUse.countByType,
|
||||
count_active_alert_history_connectors: totalInUse.countByAlertHistoryConnectorType,
|
||||
|
|
|
@ -47,6 +47,7 @@ export const stateSchemaByVersion = {
|
|||
runs: schema.number(),
|
||||
count_total: schema.number(),
|
||||
count_by_type: schema.recordOf(schema.string(), schema.number()),
|
||||
count_gen_ai_provider_types: schema.recordOf(schema.string(), schema.number()),
|
||||
count_active_total: schema.number(),
|
||||
count_active_by_type: schema.recordOf(schema.string(), schema.number()),
|
||||
count_active_alert_history_connectors: schema.number(),
|
||||
|
@ -81,6 +82,7 @@ export const emptyState: LatestTaskStateSchema = {
|
|||
runs: 0,
|
||||
count_total: 0,
|
||||
count_by_type: {},
|
||||
count_gen_ai_provider_types: {},
|
||||
count_active_total: 0,
|
||||
count_active_by_type: {},
|
||||
count_active_alert_history_connectors: 0,
|
||||
|
|
|
@ -13,6 +13,7 @@ export interface ActionsUsage {
|
|||
alert_history_connector_enabled: boolean;
|
||||
count_total: number;
|
||||
count_by_type: Record<string, number>;
|
||||
count_gen_ai_provider_types: Record<string, number>;
|
||||
count_active_total: number;
|
||||
count_active_alert_history_connectors: number;
|
||||
count_active_by_type: Record<string, number>;
|
||||
|
@ -33,6 +34,7 @@ export const byTypeSchema: MakeSchemaFrom<ActionsUsage>['count_by_type'] = {
|
|||
// Known actions:
|
||||
__email: { type: 'long' },
|
||||
__index: { type: 'long' },
|
||||
['__gen-ai']: { type: 'long' },
|
||||
__pagerduty: { type: 'long' },
|
||||
__swimlane: { type: 'long' },
|
||||
'__server-log': { type: 'long' },
|
||||
|
@ -44,6 +46,13 @@ export const byTypeSchema: MakeSchemaFrom<ActionsUsage>['count_by_type'] = {
|
|||
__teams: { type: 'long' },
|
||||
};
|
||||
|
||||
export const byGenAiProviderTypeSchema: MakeSchemaFrom<ActionsUsage>['count_by_type'] = {
|
||||
DYNAMIC_KEY: { type: 'long' },
|
||||
// Known providers:
|
||||
['Azure OpenAI']: { type: 'long' },
|
||||
['OpenAI']: { type: 'long' },
|
||||
};
|
||||
|
||||
export const byServiceProviderTypeSchema: MakeSchemaFrom<ActionsUsage>['count_active_email_connectors_by_service_type'] =
|
||||
{
|
||||
DYNAMIC_KEY: { type: 'long' },
|
||||
|
|
|
@ -21,6 +21,7 @@ import { CellActionsProvider } from '@kbn/cell-actions';
|
|||
|
||||
import { NavigationProvider } from '@kbn/security-solution-navigation';
|
||||
import { UpsellingProvider } from '../common/components/upselling_provider';
|
||||
import { useAssistantTelemetry } from '../assistant/use_assistant_telemetry';
|
||||
import { getComments } from '../assistant/get_comments';
|
||||
import { augmentMessageCodeBlocks, LOCAL_STORAGE_KEY } from '../assistant/helpers';
|
||||
import { useConversationStore } from '../assistant/use_conversation_store';
|
||||
|
@ -82,6 +83,9 @@ const StartAppComponent: FC<StartAppComponent> = ({
|
|||
const [darkMode] = useUiSetting$<boolean>(DEFAULT_DARK_MODE);
|
||||
|
||||
const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = useKibana().services.docLinks;
|
||||
|
||||
const assistantTelemetry = useAssistantTelemetry();
|
||||
|
||||
return (
|
||||
<EuiErrorBoundary>
|
||||
<i18n.Context>
|
||||
|
@ -92,6 +96,7 @@ const StartAppComponent: FC<StartAppComponent> = ({
|
|||
<AssistantProvider
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
augmentMessageCodeBlocks={augmentMessageCodeBlocks}
|
||||
assistantTelemetry={assistantTelemetry}
|
||||
defaultAllow={defaultAllow}
|
||||
defaultAllowReplacement={defaultAllowReplacement}
|
||||
docLinks={{ ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }}
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* 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 { renderHook } from '@testing-library/react-hooks';
|
||||
import { useAssistantTelemetry } from '.';
|
||||
import { BASE_SECURITY_CONVERSATIONS } from '../content/conversations';
|
||||
import { createTelemetryServiceMock } from '../../common/lib/telemetry/telemetry_service.mock';
|
||||
|
||||
const customId = `My Convo`;
|
||||
const mockedConversations = {
|
||||
...BASE_SECURITY_CONVERSATIONS,
|
||||
[customId]: {
|
||||
id: customId,
|
||||
apiConfig: {},
|
||||
messages: [],
|
||||
},
|
||||
};
|
||||
const reportAssistantInvoked = jest.fn();
|
||||
const reportAssistantMessageSent = jest.fn();
|
||||
const reportAssistantQuickPrompt = jest.fn();
|
||||
const mockedTelemetry = {
|
||||
...createTelemetryServiceMock(),
|
||||
reportAssistantInvoked,
|
||||
reportAssistantMessageSent,
|
||||
reportAssistantQuickPrompt,
|
||||
};
|
||||
|
||||
jest.mock('../use_conversation_store', () => {
|
||||
return {
|
||||
useConversationStore: () => ({
|
||||
conversations: mockedConversations,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../common/lib/kibana', () => {
|
||||
const original = jest.requireActual('../../common/lib/kibana');
|
||||
|
||||
return {
|
||||
...original,
|
||||
useKibana: () => ({
|
||||
services: {
|
||||
telemetry: mockedTelemetry,
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const trackingFns = [
|
||||
'reportAssistantInvoked',
|
||||
'reportAssistantMessageSent',
|
||||
'reportAssistantQuickPrompt',
|
||||
];
|
||||
|
||||
describe('useAssistantTelemetry', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('should return the expected telemetry object with tracking functions', () => {
|
||||
const { result } = renderHook(() => useAssistantTelemetry());
|
||||
trackingFns.forEach((fn) => {
|
||||
expect(result.current).toHaveProperty(fn);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(trackingFns)('Handles %s id masking', (fn) => {
|
||||
it('Should call tracking with appropriate id when tracking is called with an isDefault=true conversation id', () => {
|
||||
const { result } = renderHook(() => useAssistantTelemetry());
|
||||
const validId = Object.keys(mockedConversations)[0];
|
||||
// @ts-ignore
|
||||
const trackingFn = result.current[fn];
|
||||
trackingFn({ conversationId: validId, invokedBy: 'shortcut' });
|
||||
// @ts-ignore
|
||||
const trackingMockedFn = mockedTelemetry[fn];
|
||||
expect(trackingMockedFn).toHaveBeenCalledWith({
|
||||
conversationId: validId,
|
||||
invokedBy: 'shortcut',
|
||||
});
|
||||
});
|
||||
|
||||
it('Should call tracking with "Custom" id when tracking is called with an isDefault=false conversation id', () => {
|
||||
const { result } = renderHook(() => useAssistantTelemetry());
|
||||
// @ts-ignore
|
||||
const trackingFn = result.current[fn];
|
||||
trackingFn({ conversationId: customId, invokedBy: 'shortcut' });
|
||||
// @ts-ignore
|
||||
const trackingMockedFn = mockedTelemetry[fn];
|
||||
expect(trackingMockedFn).toHaveBeenCalledWith({
|
||||
conversationId: 'Custom',
|
||||
invokedBy: 'shortcut',
|
||||
});
|
||||
});
|
||||
|
||||
it('Should call tracking with "Custom" id when tracking is called with an unknown conversation id', () => {
|
||||
const { result } = renderHook(() => useAssistantTelemetry());
|
||||
// @ts-ignore
|
||||
const trackingFn = result.current[fn];
|
||||
trackingFn({ conversationId: '123', invokedBy: 'shortcut' });
|
||||
// @ts-ignore
|
||||
const trackingMockedFn = mockedTelemetry[fn];
|
||||
expect(trackingMockedFn).toHaveBeenCalledWith({
|
||||
conversationId: 'Custom',
|
||||
invokedBy: 'shortcut',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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 { AssistantTelemetry } from '@kbn/elastic-assistant';
|
||||
import { useCallback } from 'react';
|
||||
import { useConversationStore } from '../use_conversation_store';
|
||||
import { useKibana } from '../../common/lib/kibana';
|
||||
|
||||
export const useAssistantTelemetry = (): AssistantTelemetry => {
|
||||
const { conversations } = useConversationStore();
|
||||
const {
|
||||
services: { telemetry },
|
||||
} = useKibana();
|
||||
|
||||
const getAnonymizedConversationId = useCallback(
|
||||
(id) => {
|
||||
const convo = conversations[id] ?? { isDefault: false };
|
||||
return convo.isDefault ? id : 'Custom';
|
||||
},
|
||||
[conversations]
|
||||
);
|
||||
|
||||
const reportTelemetry = useCallback(
|
||||
({
|
||||
fn,
|
||||
params: { conversationId, ...rest },
|
||||
}): { fn: keyof AssistantTelemetry; params: AssistantTelemetry[keyof AssistantTelemetry] } =>
|
||||
fn({
|
||||
...rest,
|
||||
conversationId: getAnonymizedConversationId(conversationId),
|
||||
}),
|
||||
[getAnonymizedConversationId]
|
||||
);
|
||||
|
||||
return {
|
||||
reportAssistantInvoked: (params) =>
|
||||
reportTelemetry({ fn: telemetry.reportAssistantInvoked, params }),
|
||||
reportAssistantMessageSent: (params) =>
|
||||
reportTelemetry({ fn: telemetry.reportAssistantMessageSent, params }),
|
||||
reportAssistantQuickPrompt: (params) =>
|
||||
reportTelemetry({ fn: telemetry.reportAssistantQuickPrompt, params }),
|
||||
};
|
||||
};
|
|
@ -193,6 +193,7 @@ export const createStartServicesMock = (
|
|||
ml: {
|
||||
locator,
|
||||
},
|
||||
telemetry: {},
|
||||
theme: {
|
||||
theme$: themeServiceMock.createTheme$(),
|
||||
},
|
||||
|
|
|
@ -41,6 +41,9 @@ export enum TelemetryEventTypes {
|
|||
AlertsGroupingToggled = 'Alerts Grouping Toggled',
|
||||
AlertsGroupingTakeAction = 'Alerts Grouping Take Action',
|
||||
BreadcrumbClicked = 'Breadcrumb Clicked',
|
||||
AssistantInvoked = 'Assistant Invoked',
|
||||
AssistantMessageSent = 'Assistant Message Sent',
|
||||
AssistantQuickPrompt = 'Assistant Quick Prompt',
|
||||
EntityDetailsClicked = 'Entity Details Clicked',
|
||||
EntityAlertsClicked = 'Entity Alerts Clicked',
|
||||
EntityRiskFiltered = 'Entity Risk Filtered',
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { TelemetryEvent } from '../../types';
|
||||
import { TelemetryEventTypes } from '../../constants';
|
||||
|
||||
export const assistantInvokedEvent: TelemetryEvent = {
|
||||
eventType: TelemetryEventTypes.AssistantInvoked,
|
||||
schema: {
|
||||
conversationId: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'Active conversation ID',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
invokedBy: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'Invocation method',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const assistantMessageSentEvent: TelemetryEvent = {
|
||||
eventType: TelemetryEventTypes.AssistantMessageSent,
|
||||
schema: {
|
||||
conversationId: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'Active conversation ID',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
role: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'Conversation role',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const assistantQuickPrompt: TelemetryEvent = {
|
||||
eventType: TelemetryEventTypes.AssistantQuickPrompt,
|
||||
schema: {
|
||||
conversationId: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'Active conversation ID',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
promptTitle: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'Title of the quick prompt',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { RootSchema } from '@kbn/analytics-client';
|
||||
import type { TelemetryEventTypes } from '../../constants';
|
||||
|
||||
export interface ReportAssistantInvokedParams {
|
||||
conversationId: string;
|
||||
invokedBy: string;
|
||||
}
|
||||
|
||||
export interface ReportAssistantMessageSentParams {
|
||||
conversationId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface ReportAssistantQuickPromptParams {
|
||||
conversationId: string;
|
||||
promptTitle: string;
|
||||
}
|
||||
|
||||
export type ReportAssistantTelemetryEventParams =
|
||||
| ReportAssistantInvokedParams
|
||||
| ReportAssistantMessageSentParams
|
||||
| ReportAssistantQuickPromptParams;
|
||||
|
||||
export type AssistantTelemetryEvent =
|
||||
| {
|
||||
eventType: TelemetryEventTypes.AssistantInvoked;
|
||||
schema: RootSchema<ReportAssistantInvokedParams>;
|
||||
}
|
||||
| {
|
||||
eventType: TelemetryEventTypes.AssistantMessageSent;
|
||||
schema: RootSchema<ReportAssistantMessageSentParams>;
|
||||
}
|
||||
| {
|
||||
eventType: TelemetryEventTypes.AssistantQuickPrompt;
|
||||
schema: RootSchema<ReportAssistantQuickPromptParams>;
|
||||
};
|
|
@ -16,6 +16,11 @@ import {
|
|||
entityClickedEvent,
|
||||
entityRiskFilteredEvent,
|
||||
} from './entity_analytics';
|
||||
import {
|
||||
assistantInvokedEvent,
|
||||
assistantMessageSentEvent,
|
||||
assistantQuickPrompt,
|
||||
} from './ai_assistant';
|
||||
import { dataQualityIndexCheckedEvent, dataQualityCheckAllClickedEvent } from './data_quality';
|
||||
|
||||
const mlJobUpdateEvent: TelemetryEvent = {
|
||||
|
@ -130,6 +135,9 @@ export const telemetryEvents = [
|
|||
alertsGroupingToggledEvent,
|
||||
alertsGroupingChangedEvent,
|
||||
alertsGroupingTakeActionEvent,
|
||||
assistantInvokedEvent,
|
||||
assistantMessageSentEvent,
|
||||
assistantQuickPrompt,
|
||||
entityClickedEvent,
|
||||
entityAlertsClickedEvent,
|
||||
entityRiskFilteredEvent,
|
||||
|
|
|
@ -11,6 +11,9 @@ export const createTelemetryClientMock = (): jest.Mocked<TelemetryClientStart> =
|
|||
reportAlertsGroupingChanged: jest.fn(),
|
||||
reportAlertsGroupingToggled: jest.fn(),
|
||||
reportAlertsGroupingTakeAction: jest.fn(),
|
||||
reportAssistantInvoked: jest.fn(),
|
||||
reportAssistantMessageSent: jest.fn(),
|
||||
reportAssistantQuickPrompt: jest.fn(),
|
||||
reportEntityDetailsClicked: jest.fn(),
|
||||
reportEntityAlertsClicked: jest.fn(),
|
||||
reportEntityRiskFiltered: jest.fn(),
|
||||
|
|
|
@ -20,6 +20,9 @@ import type {
|
|||
ReportDataQualityIndexCheckedParams,
|
||||
ReportDataQualityCheckAllCompletedParams,
|
||||
ReportBreadcrumbClickedParams,
|
||||
ReportAssistantInvokedParams,
|
||||
ReportAssistantMessageSentParams,
|
||||
ReportAssistantQuickPromptParams,
|
||||
} from './types';
|
||||
import { TelemetryEventTypes } from './constants';
|
||||
|
||||
|
@ -42,6 +45,33 @@ export class TelemetryClient implements TelemetryClientStart {
|
|||
this.analytics.reportEvent(TelemetryEventTypes.AlertsGroupingTakeAction, params);
|
||||
};
|
||||
|
||||
public reportAssistantInvoked = ({ conversationId, invokedBy }: ReportAssistantInvokedParams) => {
|
||||
this.analytics.reportEvent(TelemetryEventTypes.AssistantInvoked, {
|
||||
conversationId,
|
||||
invokedBy,
|
||||
});
|
||||
};
|
||||
|
||||
public reportAssistantMessageSent = ({
|
||||
conversationId,
|
||||
role,
|
||||
}: ReportAssistantMessageSentParams) => {
|
||||
this.analytics.reportEvent(TelemetryEventTypes.AssistantMessageSent, {
|
||||
conversationId,
|
||||
role,
|
||||
});
|
||||
};
|
||||
|
||||
public reportAssistantQuickPrompt = ({
|
||||
conversationId,
|
||||
promptTitle,
|
||||
}: ReportAssistantQuickPromptParams) => {
|
||||
this.analytics.reportEvent(TelemetryEventTypes.AssistantQuickPrompt, {
|
||||
conversationId,
|
||||
promptTitle,
|
||||
});
|
||||
};
|
||||
|
||||
public reportEntityDetailsClicked = ({ entity }: ReportEntityDetailsClickedParams) => {
|
||||
this.analytics.reportEvent(TelemetryEventTypes.EntityDetailsClicked, {
|
||||
entity,
|
||||
|
|
|
@ -28,7 +28,15 @@ import type {
|
|||
ReportEntityDetailsClickedParams,
|
||||
ReportEntityRiskFilteredParams,
|
||||
} from './events/entity_analytics/types';
|
||||
import type {
|
||||
AssistantTelemetryEvent,
|
||||
ReportAssistantTelemetryEventParams,
|
||||
ReportAssistantInvokedParams,
|
||||
ReportAssistantQuickPromptParams,
|
||||
ReportAssistantMessageSentParams,
|
||||
} from './events/ai_assistant/types';
|
||||
|
||||
export * from './events/ai_assistant/types';
|
||||
export * from './events/alerts_grouping/types';
|
||||
export * from './events/data_quality/types';
|
||||
export type {
|
||||
|
@ -67,6 +75,7 @@ export interface ReportBreadcrumbClickedParams {
|
|||
|
||||
export type TelemetryEventParams =
|
||||
| ReportAlertsGroupingTelemetryEventParams
|
||||
| ReportAssistantTelemetryEventParams
|
||||
| ReportEntityAnalyticsTelemetryEventParams
|
||||
| ReportMLJobUpdateParams
|
||||
| ReportCellActionClickedParams
|
||||
|
@ -80,6 +89,10 @@ export interface TelemetryClientStart {
|
|||
reportAlertsGroupingToggled(params: ReportAlertsGroupingToggledParams): void;
|
||||
reportAlertsGroupingTakeAction(params: ReportAlertsTakeActionParams): void;
|
||||
|
||||
reportAssistantInvoked(params: ReportAssistantInvokedParams): void;
|
||||
reportAssistantMessageSent(params: ReportAssistantMessageSentParams): void;
|
||||
reportAssistantQuickPrompt(params: ReportAssistantQuickPromptParams): void;
|
||||
|
||||
reportEntityDetailsClicked(params: ReportEntityDetailsClickedParams): void;
|
||||
reportEntityAlertsClicked(params: ReportEntityAlertsClickedParams): void;
|
||||
reportEntityRiskFiltered(params: ReportEntityRiskFilteredParams): void;
|
||||
|
@ -94,6 +107,7 @@ export interface TelemetryClientStart {
|
|||
}
|
||||
|
||||
export type TelemetryEvent =
|
||||
| AssistantTelemetryEvent
|
||||
| AlertsGroupingTelemetryEvent
|
||||
| EntityAnalyticsTelemetryEvent
|
||||
| DataQualityTelemetryEvents
|
||||
|
|
|
@ -9,11 +9,12 @@ import { EuiBadge, EuiSkeletonText, EuiTabs, EuiTab } from '@elastic/eui';
|
|||
import { css } from '@emotion/react';
|
||||
import { Assistant } from '@kbn/elastic-assistant';
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
import type { Ref, ReactElement, ComponentType } from 'react';
|
||||
import React, { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 'react';
|
||||
import type { Ref, ReactElement, ComponentType, Dispatch, SetStateAction } from 'react';
|
||||
import React, { lazy, memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { useAssistantTelemetry } from '../../../../assistant/use_assistant_telemetry';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
|
||||
import { useConversationStore } from '../../../../assistant/use_conversation_store';
|
||||
import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability';
|
||||
|
@ -103,13 +104,22 @@ const AssistantTab: React.FC<{
|
|||
rowRenderers: RowRenderer[];
|
||||
timelineId: TimelineId;
|
||||
shouldRefocusPrompt: boolean;
|
||||
setConversationId: Dispatch<SetStateAction<string>>;
|
||||
}> = memo(
|
||||
({ isAssistantEnabled, renderCellValue, rowRenderers, timelineId, shouldRefocusPrompt }) => (
|
||||
({
|
||||
isAssistantEnabled,
|
||||
renderCellValue,
|
||||
rowRenderers,
|
||||
timelineId,
|
||||
shouldRefocusPrompt,
|
||||
setConversationId,
|
||||
}) => (
|
||||
<Suspense fallback={<EuiSkeletonText lines={10} />}>
|
||||
<AssistantTabContainer>
|
||||
<Assistant
|
||||
isAssistantEnabled={isAssistantEnabled}
|
||||
conversationId={TIMELINE_CONVERSATION_TITLE}
|
||||
setConversationId={setConversationId}
|
||||
shouldRefocusPrompt={shouldRefocusPrompt}
|
||||
/>
|
||||
</AssistantTabContainer>
|
||||
|
@ -122,6 +132,7 @@ AssistantTab.displayName = 'AssistantTab';
|
|||
type ActiveTimelineTabProps = BasicTimelineTab & {
|
||||
activeTimelineTab: TimelineTabs;
|
||||
showTimeline: boolean;
|
||||
setConversationId: Dispatch<SetStateAction<string>>;
|
||||
};
|
||||
|
||||
const ActiveTimelineTab = memo<ActiveTimelineTabProps>(
|
||||
|
@ -131,6 +142,7 @@ const ActiveTimelineTab = memo<ActiveTimelineTabProps>(
|
|||
rowRenderers,
|
||||
timelineId,
|
||||
timelineType,
|
||||
setConversationId,
|
||||
showTimeline,
|
||||
}) => {
|
||||
const isDiscoverInTimelineEnabled = useIsExperimentalFeatureEnabled('discoverInTimeline');
|
||||
|
@ -226,6 +238,7 @@ const ActiveTimelineTab = memo<ActiveTimelineTabProps>(
|
|||
renderCellValue={renderCellValue}
|
||||
rowRenderers={rowRenderers}
|
||||
timelineId={timelineId}
|
||||
setConversationId={setConversationId}
|
||||
shouldRefocusPrompt={
|
||||
showTimeline && activeTimelineTab === TimelineTabs.securityAssistant
|
||||
}
|
||||
|
@ -304,6 +317,9 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
|
|||
|
||||
const isEnterprisePlus = useLicense().isEnterprise();
|
||||
|
||||
const [conversationId, setConversationId] = useState<string>(TIMELINE_CONVERSATION_TITLE);
|
||||
const { reportAssistantInvoked } = useAssistantTelemetry();
|
||||
|
||||
const allTimelineNoteIds = useMemo(() => {
|
||||
const eventNoteIds = Object.values(eventIdToNoteIds).reduce<string[]>(
|
||||
(acc, v) => [...acc, ...v],
|
||||
|
@ -352,7 +368,13 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
|
|||
|
||||
const setSecurityAssistantAsActiveTab = useCallback(() => {
|
||||
setActiveTab(TimelineTabs.securityAssistant);
|
||||
}, [setActiveTab]);
|
||||
if (activeTab !== TimelineTabs.securityAssistant) {
|
||||
reportAssistantInvoked({
|
||||
conversationId,
|
||||
invokedBy: TIMELINE_CONVERSATION_TITLE,
|
||||
});
|
||||
}
|
||||
}, [activeTab, conversationId, reportAssistantInvoked, setActiveTab]);
|
||||
|
||||
const setDiscoverAsActiveTab = useCallback(() => {
|
||||
setActiveTab(TimelineTabs.discover);
|
||||
|
@ -470,6 +492,7 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
|
|||
timelineId={timelineId}
|
||||
timelineType={timelineType}
|
||||
timelineDescription={timelineDescription}
|
||||
setConversationId={setConversationId}
|
||||
showTimeline={showTimeline}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -31,6 +31,9 @@
|
|||
"__index": {
|
||||
"type": "long"
|
||||
},
|
||||
"[__gen-ai]": {
|
||||
"type": "long"
|
||||
},
|
||||
"__pagerduty": {
|
||||
"type": "long"
|
||||
},
|
||||
|
@ -60,6 +63,19 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"count_gen_ai_provider_types": {
|
||||
"properties": {
|
||||
"DYNAMIC_KEY": {
|
||||
"type": "long"
|
||||
},
|
||||
"[Azure OpenAI]": {
|
||||
"type": "long"
|
||||
},
|
||||
"[OpenAI]": {
|
||||
"type": "long"
|
||||
}
|
||||
}
|
||||
},
|
||||
"count_active_total": {
|
||||
"type": "long"
|
||||
},
|
||||
|
@ -80,6 +96,9 @@
|
|||
"__index": {
|
||||
"type": "long"
|
||||
},
|
||||
"[__gen-ai]": {
|
||||
"type": "long"
|
||||
},
|
||||
"__pagerduty": {
|
||||
"type": "long"
|
||||
},
|
||||
|
@ -123,6 +142,9 @@
|
|||
"__index": {
|
||||
"type": "long"
|
||||
},
|
||||
"[__gen-ai]": {
|
||||
"type": "long"
|
||||
},
|
||||
"__pagerduty": {
|
||||
"type": "long"
|
||||
},
|
||||
|
@ -194,6 +216,9 @@
|
|||
"__index": {
|
||||
"type": "long"
|
||||
},
|
||||
"[__gen-ai]": {
|
||||
"type": "long"
|
||||
},
|
||||
"__pagerduty": {
|
||||
"type": "long"
|
||||
},
|
||||
|
@ -237,6 +262,9 @@
|
|||
"__index": {
|
||||
"type": "long"
|
||||
},
|
||||
"[__gen-ai]": {
|
||||
"type": "long"
|
||||
},
|
||||
"__pagerduty": {
|
||||
"type": "long"
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue