[Security Solution] AI Assistant telemetry (#162653)

This commit is contained in:
Steph Milovic 2023-08-14 19:26:40 -06:00 committed by GitHub
parent 1857f7339d
commit d829927dbe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 848 additions and 52 deletions

View file

@ -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();
});
});

View file

@ -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(

View file

@ -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', () => {

View file

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

View file

@ -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);
});
});

View file

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

View file

@ -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(

View file

@ -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,

View file

@ -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;
}

View file

@ -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: [],
},
];

View file

@ -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>
);

View file

@ -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';

View file

@ -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,
});
});
});

View file

@ -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;

View file

@ -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: {},

View file

@ -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,

View file

@ -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,

View file

@ -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' },

View file

@ -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 }}

View file

@ -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',
});
});
});
});

View file

@ -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 }),
};
};

View file

@ -193,6 +193,7 @@ export const createStartServicesMock = (
ml: {
locator,
},
telemetry: {},
theme: {
theme$: themeServiceMock.createTheme$(),
},

View file

@ -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',

View file

@ -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,
},
},
},
};

View file

@ -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>;
};

View file

@ -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,

View file

@ -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(),

View file

@ -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,

View file

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

View file

@ -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}
/>
</>

View file

@ -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"
},