[ML] Explain Log Rate Spikes: Adds table action to copy filter to clipboard (#154311)

- Adds an action to the analysis results filter to copy a KUERY filter
for each row to the clipboard.
- Consolidates duplicate code for the Discover action into a custom
hook.
This commit is contained in:
Walter Rafelsberger 2023-04-13 17:27:21 +02:00 committed by GitHub
parent b1882495f0
commit eb7db09da8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 348 additions and 172 deletions

View file

@ -16,6 +16,7 @@ export type {
NumericChartData,
NumericHistogramField,
} from './src/fetch_histograms_for_fields';
export { isSignificantTerm } from './src/type_guards';
export type {
AggCardinality,
SignificantTerm,

View file

@ -0,0 +1,29 @@
/*
* 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 { isSignificantTerm } from './type_guards';
describe('isSignificantTerm', () => {
it('identifies significant terms', () => {
expect(isSignificantTerm({})).toBeFalsy();
expect(isSignificantTerm({ fieldName: 'response_code' })).toBeFalsy();
expect(isSignificantTerm({ fieldValue: '500' })).toBeFalsy();
expect(
isSignificantTerm({
fieldName: 'response_code',
fieldValue: '500',
doc_count: 1819,
bg_count: 553,
total_doc_count: 4671,
total_bg_count: 1975,
score: 26.546201745993947,
pValue: 2.9589053032077285e-12,
normalizedScore: 0.7814127409489161,
})
).toBeTruthy();
});
});

View file

@ -0,0 +1,32 @@
/*
* 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 { isPopulatedObject } from '@kbn/ml-is-populated-object';
import type { SignificantTerm } from './types';
/**
* Type guard for a significant term.
* Note this is used as a custom type within Explain Log Rate Spikes
* for a p-value based variant, not a generic significant terms
* aggregation type.
* @param arg The unknown type to be evaluated
* @returns whether arg is of type SignificantTerm
*/
export function isSignificantTerm(arg: unknown): arg is SignificantTerm {
return isPopulatedObject(arg, [
'fieldName',
'fieldValue',
'doc_count',
'bg_count',
'total_doc_count',
'total_bg_count',
'score',
'pValue',
'normalizedScore',
]);
}

View file

@ -58,6 +58,9 @@ export interface HistogramField {
/**
* Significant term meta data for a field/value pair.
* Note this is used as a custom type within Explain Log Rate Spikes
* for a p-value based variant, not a generic significant terms
* aggregation type.
*/
export interface SignificantTerm extends FieldValuePair {
doc_count: number;

View file

@ -0,0 +1,28 @@
/*
* 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 { finalSignificantTermGroups } from '../../../common/__mocks__/artificial_logs/final_significant_term_groups';
import { significantTerms } from '../../../common/__mocks__/artificial_logs/significant_terms';
import { getGroupTableItems } from './get_group_table_items';
import { getTableItemAsKQL } from './get_table_item_as_kql';
describe('getTableItemAsKQL', () => {
it('returns a KQL syntax for a significant term', () => {
expect(getTableItemAsKQL(significantTerms[0])).toBe('user:Peter');
expect(getTableItemAsKQL(significantTerms[1])).toBe('response_code:500');
expect(getTableItemAsKQL(significantTerms[2])).toBe('url:home.php');
expect(getTableItemAsKQL(significantTerms[3])).toBe('url:login.php');
});
it('returns a KQL syntax for a group of significant terms', () => {
const groupTableItems = getGroupTableItems(finalSignificantTermGroups);
expect(getTableItemAsKQL(groupTableItems[0])).toBe('user:Peter AND url:login.php');
expect(getTableItemAsKQL(groupTableItems[1])).toBe('response_code:500 AND url:home.php');
expect(getTableItemAsKQL(groupTableItems[2])).toBe('url:login.php AND response_code:500');
expect(getTableItemAsKQL(groupTableItems[3])).toBe('user:Peter AND url:home.php');
});
});

View file

@ -0,0 +1,23 @@
/*
* 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 { escapeKuery } from '@kbn/es-query';
import { isSignificantTerm, type SignificantTerm } from '@kbn/ml-agg-utils';
import type { GroupTableItem } from './types';
export const getTableItemAsKQL = (tableItem: GroupTableItem | SignificantTerm) => {
if (isSignificantTerm(tableItem)) {
return `${escapeKuery(tableItem.fieldName)}:${escapeKuery(String(tableItem.fieldValue))}`;
}
return [
...tableItem.groupItemsSortedByUniqueness.map(
({ fieldName, fieldValue }) => `${escapeKuery(fieldName)}:${escapeKuery(String(fieldValue))}`
),
].join(' AND ');
};

View file

@ -21,17 +21,16 @@ import {
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { escapeKuery } from '@kbn/es-query';
import type { SignificantTerm } from '@kbn/ml-agg-utils';
import { SEARCH_QUERY_LANGUAGE } from '../../application/utils/search_utils';
import { useEuiTheme } from '../../hooks/use_eui_theme';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import { MiniHistogram } from '../mini_histogram';
import { getFailedTransactionsCorrelationImpactLabel } from './get_failed_transactions_correlation_impact_label';
import { useSpikeAnalysisTableRowContext } from './spike_analysis_table_row_provider';
import { useCopyToClipboardAction } from './use_copy_to_clipboard_action';
import { useViewInDiscoverAction } from './use_view_in_discover_action';
const NARROW_COLUMN_WIDTH = '120px';
const ACTIONS_COLUMN_WIDTH = '60px';
@ -41,12 +40,6 @@ const NOT_AVAILABLE = '--';
const PAGINATION_SIZE_OPTIONS = [5, 10, 20, 50];
const DEFAULT_SORT_FIELD = 'pValue';
const DEFAULT_SORT_DIRECTION = 'asc';
const viewInDiscoverMessage = i18n.translate(
'xpack.aiops.spikeAnalysisTable.linksMenu.viewInDiscover',
{
defaultMessage: 'View in Discover',
}
);
interface SpikeAnalysisTableProps {
significantTerms: SignificantTerm[];
@ -76,63 +69,8 @@ export const SpikeAnalysisTable: FC<SpikeAnalysisTableProps> = ({
const [sortField, setSortField] = useState<keyof SignificantTerm>(DEFAULT_SORT_FIELD);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(DEFAULT_SORT_DIRECTION);
const { application, share, data } = useAiopsAppContext();
const discoverLocator = useMemo(
() => share.url.locators.get('DISCOVER_APP_LOCATOR'),
[share.url.locators]
);
const discoverUrlError = useMemo(() => {
if (!application.capabilities.discover?.show) {
const discoverNotEnabled = i18n.translate(
'xpack.aiops.spikeAnalysisTable.discoverNotEnabledErrorMessage',
{
defaultMessage: 'Discover is not enabled',
}
);
return discoverNotEnabled;
}
if (!discoverLocator) {
const discoverLocatorMissing = i18n.translate(
'xpack.aiops.spikeAnalysisTable.discoverLocatorMissingErrorMessage',
{
defaultMessage: 'No locator for Discover detected',
}
);
return discoverLocatorMissing;
}
if (!dataViewId) {
const autoGeneratedDiscoverLinkError = i18n.translate(
'xpack.aiops.spikeAnalysisTable.autoGeneratedDiscoverLinkErrorMessage',
{
defaultMessage: 'Unable to link to Discover; no data view exists for this index',
}
);
return autoGeneratedDiscoverLinkError;
}
}, [application.capabilities.discover?.show, dataViewId, discoverLocator]);
const generateDiscoverUrl = async (significantTerm: SignificantTerm) => {
if (discoverLocator !== undefined) {
const url = await discoverLocator.getRedirectUrl({
indexPatternId: dataViewId,
timeRange: data.query.timefilter.timefilter.getTime(),
filters: data.query.filterManager.getFilters(),
query: {
language: SEARCH_QUERY_LANGUAGE.KUERY,
query: `${escapeKuery(significantTerm.fieldName)}:${escapeKuery(
String(significantTerm.fieldValue)
)}`,
},
});
return url;
}
};
const copyToClipBoardAction = useCopyToClipboardAction();
const viewInDiscoverAction = useViewInDiscoverAction(dataViewId);
const columns: Array<EuiBasicTableColumn<SignificantTerm>> = [
{
@ -263,24 +201,7 @@ export const SpikeAnalysisTable: FC<SpikeAnalysisTableProps> = ({
name: i18n.translate('xpack.aiops.spikeAnalysisTable.actionsColumnName', {
defaultMessage: 'Actions',
}),
actions: [
{
name: () => (
<EuiToolTip content={discoverUrlError ? discoverUrlError : viewInDiscoverMessage}>
<EuiIcon type="discoverApp" />
</EuiToolTip>
),
description: viewInDiscoverMessage,
type: 'button',
onClick: async (significantTerm) => {
const openInDiscoverUrl = await generateDiscoverUrl(significantTerm);
if (typeof openInDiscoverUrl === 'string') {
await application.navigateToUrl(openInDiscoverUrl);
}
},
enabled: () => discoverUrlError === undefined,
},
],
actions: [viewInDiscoverAction, copyToClipBoardAction],
width: ACTIONS_COLUMN_WIDTH,
valign: 'top',
},

View file

@ -26,19 +26,17 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { escapeKuery } from '@kbn/es-query';
import { FormattedMessage } from '@kbn/i18n-react';
import type { SignificantTerm } from '@kbn/ml-agg-utils';
import { SEARCH_QUERY_LANGUAGE } from '../../application/utils/search_utils';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import { MiniHistogram } from '../mini_histogram';
import { getFailedTransactionsCorrelationImpactLabel } from './get_failed_transactions_correlation_impact_label';
import { SpikeAnalysisTable } from './spike_analysis_table';
import { useSpikeAnalysisTableRowContext } from './spike_analysis_table_row_provider';
import type { GroupTableItem } from './types';
import { useCopyToClipboardAction } from './use_copy_to_clipboard_action';
import { useViewInDiscoverAction } from './use_view_in_discover_action';
const NARROW_COLUMN_WIDTH = '120px';
const EXPAND_COLUMN_WIDTH = '40px';
@ -49,12 +47,6 @@ const MAX_GROUP_BADGES = 5;
const PAGINATION_SIZE_OPTIONS = [5, 10, 20, 50];
const DEFAULT_SORT_FIELD = 'pValue';
const DEFAULT_SORT_DIRECTION = 'asc';
const viewInDiscoverMessage = i18n.translate(
'xpack.aiops.spikeAnalysisTable.linksMenu.viewInDiscover',
{
defaultMessage: 'View in Discover',
}
);
interface SpikeAnalysisTableProps {
significantTerms: SignificantTerm[];
@ -117,66 +109,8 @@ export const SpikeAnalysisGroupsTable: FC<SpikeAnalysisTableProps> = ({
setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues);
};
const { application, share, data } = useAiopsAppContext();
const discoverLocator = useMemo(
() => share.url.locators.get('DISCOVER_APP_LOCATOR'),
[share.url.locators]
);
const discoverUrlError = useMemo(() => {
if (!application.capabilities.discover?.show) {
const discoverNotEnabled = i18n.translate(
'xpack.aiops.spikeAnalysisTable.discoverNotEnabledErrorMessage',
{
defaultMessage: 'Discover is not enabled',
}
);
return discoverNotEnabled;
}
if (!discoverLocator) {
const discoverLocatorMissing = i18n.translate(
'xpack.aiops.spikeAnalysisTable.discoverLocatorMissingErrorMessage',
{
defaultMessage: 'No locator for Discover detected',
}
);
return discoverLocatorMissing;
}
if (!dataViewId) {
const autoGeneratedDiscoverLinkError = i18n.translate(
'xpack.aiops.spikeAnalysisTable.autoGeneratedDiscoverLinkErrorMessage',
{
defaultMessage: 'Unable to link to Discover; no data view exists for this index',
}
);
return autoGeneratedDiscoverLinkError;
}
}, [application.capabilities.discover?.show, dataViewId, discoverLocator]);
const generateDiscoverUrl = async (groupTableItem: GroupTableItem) => {
if (discoverLocator !== undefined) {
const url = await discoverLocator.getRedirectUrl({
indexPatternId: dataViewId,
timeRange: data.query.timefilter.timefilter.getTime(),
filters: data.query.filterManager.getFilters(),
query: {
language: SEARCH_QUERY_LANGUAGE.KUERY,
query: [
...groupTableItem.groupItemsSortedByUniqueness.map(
({ fieldName, fieldValue }) =>
`${escapeKuery(fieldName)}:${escapeKuery(String(fieldValue))}`
),
].join(' AND '),
},
});
return url;
}
};
const copyToClipBoardAction = useCopyToClipboardAction();
const viewInDiscoverAction = useViewInDiscoverAction(dataViewId);
const columns: Array<EuiBasicTableColumn<GroupTableItem>> = [
{
@ -411,24 +345,7 @@ export const SpikeAnalysisGroupsTable: FC<SpikeAnalysisTableProps> = ({
name: i18n.translate('xpack.aiops.spikeAnalysisTable.actionsColumnName', {
defaultMessage: 'Actions',
}),
actions: [
{
name: () => (
<EuiToolTip content={discoverUrlError ? discoverUrlError : viewInDiscoverMessage}>
<EuiIcon type="discoverApp" />
</EuiToolTip>
),
description: viewInDiscoverMessage,
type: 'button',
onClick: async (tableItem) => {
const openInDiscoverUrl = await generateDiscoverUrl(tableItem);
if (typeof openInDiscoverUrl === 'string') {
await application.navigateToUrl(openInDiscoverUrl);
}
},
enabled: () => discoverUrlError === undefined,
},
],
actions: [viewInDiscoverAction, copyToClipBoardAction],
width: ACTIONS_COLUMN_WIDTH,
valign: 'top',
},

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import type { EuiTableActionsColumnType } from '@elastic/eui';
import type { SignificantTerm, SignificantTermGroupItem } from '@kbn/ml-agg-utils';
export type GroupTableItemGroup = Pick<
@ -20,3 +22,7 @@ export interface GroupTableItem {
groupItemsSortedByUniqueness: GroupTableItemGroup[];
histogram: SignificantTerm['histogram'];
}
export type TableItemAction = EuiTableActionsColumnType<
SignificantTerm | GroupTableItem
>['actions'][number];

View file

@ -0,0 +1,70 @@
/*
* 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 { ReactElement } from 'react';
import userEvent from '@testing-library/user-event';
import { render, act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import type { SignificantTerm } from '@kbn/ml-agg-utils';
import { finalSignificantTermGroups } from '../../../common/__mocks__/artificial_logs/final_significant_term_groups';
import { significantTerms } from '../../../common/__mocks__/artificial_logs/significant_terms';
import { getGroupTableItems } from './get_group_table_items';
import { useCopyToClipboardAction } from './use_copy_to_clipboard_action';
import type { GroupTableItem } from './types';
interface Action {
render: (tableItem: SignificantTerm | GroupTableItem) => ReactElement;
}
const execCommandMock = (global.document.execCommand = jest.fn());
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
describe('useCopyToClipboardAction', () => {
it('renders the action for a single significant term', async () => {
execCommandMock.mockImplementationOnce(() => true);
const { result } = renderHook(() => useCopyToClipboardAction());
const { getByLabelText } = render((result.current as Action).render(significantTerms[0]));
const button = getByLabelText('Copy field/value pair as KQL syntax to clipboard');
expect(button).toBeInTheDocument();
await act(async () => {
await userEvent.click(button);
});
// EUI implements copy-to-clipboard with deprecated `document.execCommand`.
// We can assert that is has been triggered, but the combo with jsdom doesn't
// give us a way to assert the actual value that has been copied to the clipboard.
expect(execCommandMock).toHaveBeenCalledWith('copy');
expect(warn).not.toHaveBeenCalled();
});
it('renders the action for a group of items', async () => {
execCommandMock.mockImplementationOnce(() => true);
const groupTableItems = getGroupTableItems(finalSignificantTermGroups);
const { result } = renderHook(() => useCopyToClipboardAction());
const { getByLabelText } = render((result.current as Action).render(groupTableItems[0]));
const button = getByLabelText('Copy group items as KQL syntax to clipboard');
expect(button).toBeInTheDocument();
await act(async () => {
await userEvent.click(button);
});
// EUI implements copy-to-clipboard with deprecated `document.execCommand`.
// We can assert that is has been triggered, but the combo with jsdom doesn't
// give us a way to assert the actual value that has been copied to the clipboard.
expect(execCommandMock).toHaveBeenCalledWith('copy');
expect(warn).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,45 @@
/*
* 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 { EuiCopy, EuiToolTip, EuiButtonIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isSignificantTerm, type SignificantTerm } from '@kbn/ml-agg-utils';
import { getTableItemAsKQL } from './get_table_item_as_kql';
import type { GroupTableItem, TableItemAction } from './types';
const copyToClipboardSignificantTermMessage = i18n.translate(
'xpack.aiops.spikeAnalysisTable.linksMenu.copyToClipboardSignificantTermMessage',
{
defaultMessage: 'Copy field/value pair as KQL syntax to clipboard',
}
);
const copyToClipboardGroupMessage = i18n.translate(
'xpack.aiops.spikeAnalysisTable.linksMenu.copyToClipboardGroupMessage',
{
defaultMessage: 'Copy group items as KQL syntax to clipboard',
}
);
export const useCopyToClipboardAction = (): TableItemAction => ({
render: (tableItem: SignificantTerm | GroupTableItem) => {
const message = isSignificantTerm(tableItem)
? copyToClipboardSignificantTermMessage
: copyToClipboardGroupMessage;
return (
<EuiToolTip content={message}>
<EuiCopy textToCopy={getTableItemAsKQL(tableItem)}>
{(copy) => <EuiButtonIcon iconType="copyClipboard" onClick={copy} aria-label={message} />}
</EuiCopy>
</EuiToolTip>
);
},
});

View file

@ -0,0 +1,101 @@
/*
* 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, { useMemo } from 'react';
import { EuiIcon, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { SignificantTerm } from '@kbn/ml-agg-utils';
import { SEARCH_QUERY_LANGUAGE } from '../../application/utils/search_utils';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import { getTableItemAsKQL } from './get_table_item_as_kql';
import type { GroupTableItem, TableItemAction } from './types';
const viewInDiscoverMessage = i18n.translate(
'xpack.aiops.spikeAnalysisTable.linksMenu.viewInDiscover',
{
defaultMessage: 'View in Discover',
}
);
export const useViewInDiscoverAction = (dataViewId?: string): TableItemAction => {
const { application, share, data } = useAiopsAppContext();
const discoverLocator = useMemo(
() => share.url.locators.get('DISCOVER_APP_LOCATOR'),
[share.url.locators]
);
const discoverUrlError = useMemo(() => {
if (!application.capabilities.discover?.show) {
const discoverNotEnabled = i18n.translate(
'xpack.aiops.spikeAnalysisTable.discoverNotEnabledErrorMessage',
{
defaultMessage: 'Discover is not enabled',
}
);
return discoverNotEnabled;
}
if (!discoverLocator) {
const discoverLocatorMissing = i18n.translate(
'xpack.aiops.spikeAnalysisTable.discoverLocatorMissingErrorMessage',
{
defaultMessage: 'No locator for Discover detected',
}
);
return discoverLocatorMissing;
}
if (!dataViewId) {
const autoGeneratedDiscoverLinkError = i18n.translate(
'xpack.aiops.spikeAnalysisTable.autoGeneratedDiscoverLinkErrorMessage',
{
defaultMessage: 'Unable to link to Discover; no data view exists for this index',
}
);
return autoGeneratedDiscoverLinkError;
}
}, [application.capabilities.discover?.show, dataViewId, discoverLocator]);
const generateDiscoverUrl = async (groupTableItem: GroupTableItem | SignificantTerm) => {
if (discoverLocator !== undefined) {
const url = await discoverLocator.getRedirectUrl({
indexPatternId: dataViewId,
timeRange: data.query.timefilter.timefilter.getTime(),
filters: data.query.filterManager.getFilters(),
query: {
language: SEARCH_QUERY_LANGUAGE.KUERY,
query: getTableItemAsKQL(groupTableItem),
},
});
return url;
}
};
return {
name: () => (
<EuiToolTip content={discoverUrlError ? discoverUrlError : viewInDiscoverMessage}>
<EuiIcon type="discoverApp" />
</EuiToolTip>
),
description: viewInDiscoverMessage,
type: 'button',
onClick: async (tableItem) => {
const openInDiscoverUrl = await generateDiscoverUrl(tableItem);
if (typeof openInDiscoverUrl === 'string') {
await application.navigateToUrl(openInDiscoverUrl);
}
},
enabled: () => discoverUrlError === undefined,
};
};