[search/public] expose showWarnings(inspector) method on search service (#138342)

* add showWarning to search service

* add comments

* add unit tests

* test foo

* cleanup

* add s to property name in test

* comments for api items

* use the warnings when calling showWarnings

* change showWarning to just show a single warning

* put handleWarnings on the request adapter

* comment

* simplify 1

* fix lens unit test

* remove underscoring for unused variables

* revert inspector changes, extract the response warnings in the search service

* fix bug

* remove console.log

* re-apply typescript fixes to app test code

* declutter

* add test, improve comments

* fix some unexported public api items

* include rawResponse in the warning structure

* fix lint

* tweak clean up example app

* SearchResponseWarnings and SearchResponseWarningNotification

* fix export bug

* not include shardStats if there are no warnings

* Update src/plugins/data/common/search/types.ts

* simplify SearchResponseWarnings interface

* remove array copying

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* comments for api_docs

* simplify per feedback

* Pass callback to handleResponse in showWarnings

* export more public types

* update example to make possible to show shard failure

* pr cleanup

* eslint fix

* allow example app to not show default warnings

* move extractWarning and related types to inspector plugin

* wip functional test of example app

* fix test references

* finish functional test

* relocate extractWarnings back to search/fetch

* fix test

* remove need for isTimeout, isShardFailure

* ts fix

* improve test

* Change showWarnings to accept the RequestAdapter

* use showWarnings in vis_types/timeseries

* more tests

* use handle_warning name

* fix ts

* add reason field to SearchResponseWarning

* fix component snapshot

* update comments

* test cleanup

* fix test

* ensure notification appears only once

* fix and cleanup

* fix ts

* fix response.json bug

* use top-level type, and lower-level reason.type

* cleanup

* fix shard failure warning in tsvb per feedback

cc @flash1293

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Joe Reuter <johannes.reuter@elastic.co>
This commit is contained in:
Tim Sullivan 2022-08-31 11:22:24 -07:00 committed by GitHub
parent 28ab14cbe1
commit 160058a8c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1291 additions and 367 deletions

View file

@ -9,7 +9,7 @@
"description": "Example plugin of how to use data plugin search services",
"server": true,
"ui": true,
"requiredPlugins": ["navigation", "data", "developerExamples", "kibanaUtils", "share", "unifiedSearch"],
"requiredPlugins": ["navigation", "data", "developerExamples", "inspector", "kibanaUtils", "share", "unifiedSearch"],
"optionalPlugins": [],
"requiredBundles": ["kibanaReact"],
"owner": {

View file

@ -6,48 +6,48 @@
* Side Public License, v 1.
*/
import React, { useState, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiButtonEmpty,
EuiCheckbox,
EuiCode,
EuiCodeBlock,
EuiComboBox,
EuiFieldNumber,
EuiFlexGrid,
EuiFlexItem,
EuiFormLabel,
EuiHorizontalRule,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageHeader,
EuiTitle,
EuiText,
EuiFlexGrid,
EuiFlexItem,
EuiCheckbox,
EuiSpacer,
EuiCode,
EuiComboBox,
EuiFormLabel,
EuiFieldNumber,
EuiProgress,
EuiSpacer,
EuiTabbedContent,
EuiTabbedContentTab,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { lastValueFrom } from 'rxjs';
import { CoreStart } from '@kbn/core/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import { IInspectorInfo } from '@kbn/data-plugin/common';
import {
DataPublicPluginStart,
IKibanaSearchResponse,
isCompleteResponse,
isErrorResponse,
} from '@kbn/data-plugin/public';
import { SearchResponseWarning } from '@kbn/data-plugin/public/search/types';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { RequestAdapter } from '@kbn/inspector-plugin/common';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { DataViewField, DataView } from '@kbn/data-views-plugin/public';
import { AbortError } from '@kbn/kibana-utils-plugin/common';
import { IMyStrategyResponse } from '../../common/types';
import React, { useEffect, useState } from 'react';
import { lastValueFrom } from 'rxjs';
import { PLUGIN_ID, PLUGIN_NAME, SERVER_SEARCH_ROUTE_PATH } from '../../common';
import { IMyStrategyResponse } from '../../common/types';
interface SearchExamplesAppDeps {
notifications: CoreStart['notifications'];
@ -82,6 +82,9 @@ function formatFieldsToComboBox(fields?: DataViewField[]) {
});
}
const bucketAggType = 'terms';
const metricAggType = 'median';
export const SearchExamplesApp = ({
http,
notifications,
@ -108,9 +111,11 @@ export const SearchExamplesApp = ({
const [isLoading, setIsLoading] = useState<boolean>(false);
const [currentAbortController, setAbortController] = useState<AbortController>();
const [rawResponse, setRawResponse] = useState<Record<string, any>>({});
const [warningContents, setWarningContents] = useState<SearchResponseWarning[]>([]);
const [selectedTab, setSelectedTab] = useState(0);
function setResponse(response: IKibanaSearchResponse) {
setWarningContents([]);
setRawResponse(response.rawResponse);
setLoaded(response.loaded!);
setTotal(response.total!);
@ -177,7 +182,7 @@ export const SearchExamplesApp = ({
}
// Construct the aggregations portion of the search request by using the `data.search.aggs` service.
const aggs = [{ type: 'avg', params: { field: selectedNumericField!.name } }];
const aggs = [{ type: metricAggType, params: { field: selectedNumericField!.name } }];
const aggsDsl = data.search.aggs.createAggConfigs(dataView, aggs).toDsl();
const req = {
@ -210,7 +215,7 @@ export const SearchExamplesApp = ({
if (isCompleteResponse(res)) {
setIsLoading(false);
setResponse(res);
const avgResult: number | undefined = res.rawResponse.aggregations
const aggResult: number | undefined = res.rawResponse.aggregations
? // @ts-expect-error @elastic/elasticsearch no way to declare a type for aggregation in the search response
res.rawResponse.aggregations[1].value
: undefined;
@ -219,8 +224,8 @@ export const SearchExamplesApp = ({
const message = (
<EuiText>
Searched {res.rawResponse.hits.total} documents. <br />
The average of {selectedNumericField!.name} is{' '}
{avgResult ? Math.floor(avgResult) : 0}.
The ${metricAggType} of {selectedNumericField!.name} is{' '}
{aggResult ? Math.floor(aggResult) : 0}.
<br />
{isCool ? `Is it Cool? ${isCool}` : undefined}
<br />
@ -251,21 +256,15 @@ export const SearchExamplesApp = ({
},
error: (e) => {
setIsLoading(false);
if (e instanceof AbortError) {
notifications.toasts.addWarning({
title: e.message,
});
} else {
notifications.toasts.addDanger({
title: 'Failed to run search',
text: e.message,
});
}
data.search.showError(e);
},
});
};
const doSearchSourceSearch = async (otherBucket: boolean) => {
const doSearchSourceSearch = async (
otherBucket: boolean,
showWarningToastNotifications = true
) => {
if (!dataView) return;
const query = data.query.queryString.getQuery();
@ -289,29 +288,58 @@ export const SearchExamplesApp = ({
const aggDef = [];
if (selectedBucketField) {
aggDef.push({
type: 'terms',
type: bucketAggType,
schema: 'split',
params: { field: selectedBucketField.name, size: 2, otherBucket },
});
}
if (selectedNumericField) {
aggDef.push({ type: 'avg', params: { field: selectedNumericField.name } });
aggDef.push({ type: metricAggType, params: { field: selectedNumericField.name } });
}
if (aggDef.length > 0) {
const ac = data.search.aggs.createAggConfigs(dataView, aggDef);
searchSource.setField('aggs', ac);
}
setRequest(searchSource.getSearchRequestBody());
const abortController = new AbortController();
const inspector: Required<IInspectorInfo> = {
adapter: new RequestAdapter(),
title: 'Example App Inspector!',
id: 'greatest-example-app-inspector',
description: 'Use the `description` field for more info about the inspector.',
};
setAbortController(abortController);
setIsLoading(true);
const { rawResponse: res } = await lastValueFrom(
searchSource.fetch$({ abortSignal: abortController.signal })
const result = await lastValueFrom(
searchSource.fetch$({
abortSignal: abortController.signal,
disableShardFailureWarning: !showWarningToastNotifications,
inspector,
})
);
setRawResponse(res);
setRawResponse(result.rawResponse);
const message = <EuiText>Searched {res.hits.total} documents.</EuiText>;
/* Here is an example of using showWarnings on the search service, using an optional callback to
* intercept the warnings before notification warnings are shown.
*
* Suppressing the shard failure warning notification from appearing by default requires setting
* { disableShardFailureWarning: true } in the SearchSourceSearchOptions passed to $fetch
*/
if (showWarningToastNotifications) {
setWarningContents([]);
} else {
const warnings: SearchResponseWarning[] = [];
data.search.showWarnings(inspector.adapter, (warning) => {
warnings.push(warning);
return false; // allow search service from showing this warning on its own
});
// click the warnings tab to see the warnings
setWarningContents(warnings);
}
const message = <EuiText>Searched {result.rawResponse.hits.total} documents.</EuiText>;
notifications.toasts.addSuccess(
{
title: 'Query result',
@ -323,16 +351,7 @@ export const SearchExamplesApp = ({
);
} catch (e) {
setRawResponse(e.body);
if (e instanceof AbortError) {
notifications.toasts.addWarning({
title: e.message,
});
} else {
notifications.toasts.addDanger({
title: 'Failed to run search',
text: e.message,
});
}
data.search.showError(e);
} finally {
setIsLoading(false);
}
@ -390,16 +409,7 @@ export const SearchExamplesApp = ({
},
error: (e) => {
setIsLoading(false);
if (e instanceof AbortError) {
notifications.toasts.addWarning({
title: e.message,
});
} else {
notifications.toasts.addDanger({
title: 'Failed to run search',
text: e.message,
});
}
data.search.showError(e);
},
});
};
@ -424,23 +434,17 @@ export const SearchExamplesApp = ({
notifications.toasts.addSuccess(`Server returned ${JSON.stringify(res)}`);
} catch (e) {
if (e?.name === 'AbortError') {
notifications.toasts.addWarning({
title: e.message,
});
} else {
notifications.toasts.addDanger({
title: 'Failed to run search',
text: e.message,
});
}
data.search.showError(e);
} finally {
setIsLoading(false);
}
};
const onSearchSourceClickHandler = (withOtherBucket: boolean) => {
doSearchSourceSearch(withOtherBucket);
const onSearchSourceClickHandler = (
withOtherBucket: boolean,
showWarningToastNotifications: boolean
) => {
doSearchSourceSearch(withOtherBucket, showWarningToastNotifications);
};
const reqTabs: EuiTabbedContentTab[] = [
@ -491,6 +495,35 @@ export const SearchExamplesApp = ({
</>
),
},
{
id: 'warnings',
name: <EuiText data-test-subj="warningsTab">Warnings</EuiText>,
content: (
<>
{' '}
<EuiSpacer />{' '}
<EuiText size="xs">
{' '}
<FormattedMessage
id="searchExamples.warningsObject"
defaultMessage="Timeout and shard failure warnings for high-level search may be handled in a callback to the showWarnings method on the search service."
/>{' '}
</EuiText>{' '}
<EuiProgress value={loaded} max={total} size="xs" data-test-subj="progressBar" />{' '}
<EuiCodeBlock
language="json"
fontSize="s"
paddingSize="s"
overflowHeight={450}
isCopyable
data-test-subj="warningsCodeBlock"
>
{' '}
{JSON.stringify(warningContents, null, 2)}{' '}
</EuiCodeBlock>{' '}
</>
),
},
];
return (
@ -535,7 +568,7 @@ export const SearchExamplesApp = ({
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormLabel>Field (bucket)</EuiFormLabel>
<EuiFormLabel>Field (using {bucketAggType} buckets)</EuiFormLabel>
<EuiComboBox
options={formatFieldsToComboBox(getAggregatableStrings(fields))}
selectedOptions={formatFieldToComboBox(selectedBucketField)}
@ -553,7 +586,7 @@ export const SearchExamplesApp = ({
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormLabel>Numeric Field (metric)</EuiFormLabel>
<EuiFormLabel>Numeric Field (using {metricAggType} metrics)</EuiFormLabel>
<EuiComboBox
options={formatFieldsToComboBox(getNumeric(fields))}
selectedOptions={formatFieldToComboBox(selectedNumericField)}
@ -586,6 +619,9 @@ export const SearchExamplesApp = ({
/>
</EuiFlexItem>
</EuiFlexGrid>
<EuiHorizontalRule />
<EuiFlexGrid columns={2}>
<EuiFlexItem style={{ width: '40%' }}>
<EuiSpacer />
@ -613,7 +649,7 @@ export const SearchExamplesApp = ({
</EuiText>
<EuiButtonEmpty
size="xs"
onClick={() => onSearchSourceClickHandler(true)}
onClick={() => onSearchSourceClickHandler(true, true)}
iconType="play"
data-test-subj="searchSourceWithOther"
>
@ -625,12 +661,12 @@ export const SearchExamplesApp = ({
<EuiText size="xs" color="subdued" className="searchExampleStepDsc">
<FormattedMessage
id="searchExamples.buttonText"
defaultMessage="Bucket and metrics aggregations with other bucket."
defaultMessage="Bucket and metrics aggregations, with other bucket and default warnings."
/>
</EuiText>
<EuiButtonEmpty
size="xs"
onClick={() => onSearchSourceClickHandler(false)}
onClick={() => onSearchSourceClickHandler(false, false)}
iconType="play"
data-test-subj="searchSourceWithoutOther"
>
@ -642,7 +678,7 @@ export const SearchExamplesApp = ({
<EuiText size="xs" color="subdued" className="searchExampleStepDsc">
<FormattedMessage
id="searchExamples.buttonText"
defaultMessage="Bucket and metrics aggregations without other bucket."
defaultMessage="Bucket and metrics aggregations, without other bucket and with custom logic to handle warnings."
/>
</EuiText>
</EuiText>

View file

@ -16,6 +16,7 @@
{ "path": "../../src/core/tsconfig.json" },
{ "path": "../../src/plugins/data/tsconfig.json" },
{ "path": "../../src/plugins/data_views/tsconfig.json" },
{ "path": "../../src/plugins/inspector/tsconfig.json" },
{ "path": "../../src/plugins/kibana_utils/tsconfig.json" },
{ "path": "../../src/plugins/kibana_react/tsconfig.json" },
{ "path": "../../src/plugins/navigation/tsconfig.json" },

View file

@ -16,6 +16,7 @@ import { IKibanaSearchResponse } from '../../types';
* This type is used when flattenning a SearchSource and passing it down to legacy search.
* Once legacy search is removed, this type should become internal to `SearchSource`,
* where `ISearchRequestParams` is used externally instead.
* FIXME: replace with estypes.SearchRequest?
*/
export type SearchRequest = Record<string, any>;

View file

@ -181,7 +181,6 @@ export {
SearchSource,
SearchSessionState,
SortDirection,
handleResponse,
} from './search';
export type {

View file

@ -0,0 +1,130 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { estypes } from '@elastic/elasticsearch';
import { extractWarnings } from './extract_warnings';
describe('extract search response warnings', () => {
it('should extract warnings from response with shard failures', () => {
const response = {
took: 25,
timed_out: false,
_shards: {
total: 4,
successful: 2,
skipped: 0,
failed: 2,
failures: [
{
shard: 0,
index: 'sample-01-rollup',
node: 'VFTFJxpHSdaoiGxJFLSExQ',
reason: {
type: 'illegal_argument_exception',
reason:
'Field [kubernetes.container.memory.available.bytes] of type [aggregate_metric_double] is not supported for aggregation [percentiles]',
},
},
],
},
hits: { total: 18239, max_score: null, hits: [] },
aggregations: {},
};
expect(extractWarnings(response)).toEqual([
{
type: 'shard_failure',
message: '2 of 4 shards failed',
reason: {
type: 'illegal_argument_exception',
reason:
'Field [kubernetes.container.memory.available.bytes] of type' +
' [aggregate_metric_double] is not supported for aggregation [percentiles]',
},
text: 'The data you are seeing might be incomplete or wrong.',
},
]);
});
it('should extract timeout warning', () => {
const warnings = {
took: 999,
timed_out: true,
_shards: {} as estypes.ShardStatistics,
hits: { hits: [] },
};
expect(extractWarnings(warnings)).toEqual([
{
type: 'timed_out',
message: 'Data might be incomplete because your request timed out',
},
]);
});
it('should extract shards failed warnings', () => {
const warnings = {
_shards: {
failed: 77,
total: 79,
},
} as estypes.SearchResponse;
expect(extractWarnings(warnings)).toEqual([
{
type: 'shard_failure',
message: '77 of 79 shards failed',
reason: { type: 'generic_shard_warning' },
text: 'The data you are seeing might be incomplete or wrong.',
},
]);
});
it('should extract shards failed warning failure reason type', () => {
const warnings = extractWarnings({
_shards: {
failed: 77,
total: 79,
},
} as estypes.SearchResponse);
expect(warnings).toEqual([
{
type: 'shard_failure',
message: '77 of 79 shards failed',
reason: { type: 'generic_shard_warning' },
text: 'The data you are seeing might be incomplete or wrong.',
},
]);
});
it('extracts multiple warnings', () => {
const warnings = extractWarnings({
timed_out: true,
_shards: {
failed: 77,
total: 79,
},
} as estypes.SearchResponse);
const [shardFailures, timedOut] = [
warnings.filter(({ type }) => type !== 'timed_out'),
warnings.filter(({ type }) => type === 'timed_out'),
];
expect(shardFailures[0]!.message).toBeDefined();
expect(timedOut[0]!.message).toBeDefined();
});
it('should not include shardStats or types fields if there are no warnings', () => {
const warnings = extractWarnings({
timed_out: false,
_shards: {
failed: 0,
total: 9000,
},
} as estypes.SearchResponse);
expect(warnings).toEqual([]);
});
});

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { estypes } from '@elastic/elasticsearch';
import { i18n } from '@kbn/i18n';
import { SearchResponseWarning } from '../types';
/**
* @internal
*/
export function extractWarnings(rawResponse: estypes.SearchResponse): SearchResponseWarning[] {
const warnings: SearchResponseWarning[] = [];
if (rawResponse.timed_out === true) {
warnings.push({
type: 'timed_out',
message: i18n.translate('data.search.searchSource.fetch.requestTimedOutNotificationMessage', {
defaultMessage: 'Data might be incomplete because your request timed out',
}),
reason: undefined, // exists so that callers do not have to cast when working with shard warnings.
});
}
if (rawResponse._shards && rawResponse._shards.failed) {
const message = i18n.translate(
'data.search.searchSource.fetch.shardsFailedNotificationMessage',
{
defaultMessage: '{shardsFailed} of {shardsTotal} shards failed',
values: {
shardsFailed: rawResponse._shards.failed,
shardsTotal: rawResponse._shards.total,
},
}
);
const text = i18n.translate(
'data.search.searchSource.fetch.shardsFailedNotificationDescription',
{ defaultMessage: 'The data you are seeing might be incomplete or wrong.' }
);
if (rawResponse._shards.failures) {
rawResponse._shards.failures?.forEach((f) => {
warnings.push({
type: 'shard_failure',
message,
text,
reason: {
type: f.reason.type,
reason: f.reason.reason,
},
});
});
} else {
// unknown type and reason
warnings.push({
type: 'shard_failure',
message,
text,
reason: { type: 'generic_shard_warning' },
});
}
}
return warnings;
}

View file

@ -1,99 +0,0 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { handleResponse } from './handle_response';
// Temporary disable eslint, will be removed after moving to new platform folder
import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
import { setNotifications } from '../../services';
import { SearchSourceSearchOptions } from '../../../common';
import { themeServiceMock } from '@kbn/core/public/mocks';
jest.mock('@kbn/i18n', () => {
return {
i18n: {
translate: (_id: string, { defaultMessage }: { defaultMessage: string }) => defaultMessage,
},
};
});
const theme = themeServiceMock.createStartContract();
describe('handleResponse', () => {
const notifications = notificationServiceMock.createStartContract();
let options: SearchSourceSearchOptions;
beforeEach(() => {
setNotifications(notifications);
(notifications.toasts.addWarning as jest.Mock).mockReset();
options = { disableShardFailureWarning: false };
});
test('should notify if timed out', () => {
const request = { body: {} };
const response = {
rawResponse: {
timed_out: true,
},
};
const result = handleResponse(request, response, options, theme);
expect(result).toBe(response);
expect(notifications.toasts.addWarning).toBeCalled();
expect((notifications.toasts.addWarning as jest.Mock).mock.calls[0][0].title).toMatch(
'request timed out'
);
});
test('should notify if shards failed', () => {
const request = { body: {} };
const response = {
rawResponse: {
_shards: {
failed: 1,
total: 2,
successful: 1,
skipped: 1,
},
},
};
const result = handleResponse(request, response, options, theme);
expect(result).toBe(response);
expect(notifications.toasts.addWarning).toBeCalled();
expect((notifications.toasts.addWarning as jest.Mock).mock.calls[0][0].title).toMatch(
'shards failed'
);
});
test('should not notify of shards failed if disableShardFailureWarning is true', () => {
options.disableShardFailureWarning = true;
const request = { body: {} };
const response = {
rawResponse: {
_shards: {
failed: 1,
total: 2,
successful: 1,
skipped: 1,
},
},
};
const result = handleResponse(request, response, options, theme);
expect(result).toBe(response);
expect(notifications.toasts.addWarning).not.toBeCalled();
});
test('returns the response', () => {
const request = {};
const response = {
rawResponse: {},
};
const result = handleResponse(request, response, options, theme);
expect(result).toBe(response);
});
});

View file

@ -1,68 +0,0 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSpacer } from '@elastic/eui';
import { ThemeServiceStart } from '@kbn/core/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { IKibanaSearchResponse, SearchSourceSearchOptions } from '../../../common';
import { ShardFailureOpenModalButton } from '../../shard_failure_modal';
import { getNotifications } from '../../services';
import type { SearchRequest } from '..';
export function handleResponse(
request: SearchRequest,
response: IKibanaSearchResponse,
{ disableShardFailureWarning }: SearchSourceSearchOptions,
theme: ThemeServiceStart
) {
const { rawResponse } = response;
if (rawResponse.timed_out) {
getNotifications().toasts.addWarning({
title: i18n.translate('data.search.searchSource.fetch.requestTimedOutNotificationMessage', {
defaultMessage: 'Data might be incomplete because your request timed out',
}),
});
}
if (rawResponse._shards && rawResponse._shards.failed && !disableShardFailureWarning) {
const title = i18n.translate('data.search.searchSource.fetch.shardsFailedNotificationMessage', {
defaultMessage: '{shardsFailed} of {shardsTotal} shards failed',
values: {
shardsFailed: rawResponse._shards.failed,
shardsTotal: rawResponse._shards.total,
},
});
const description = i18n.translate(
'data.search.searchSource.fetch.shardsFailedNotificationDescription',
{
defaultMessage: 'The data you are seeing might be incomplete or wrong.',
}
);
const text = toMountPoint(
<>
{description}
<EuiSpacer size="s" />
<ShardFailureOpenModalButton
request={request.body}
response={rawResponse}
theme={theme}
title={title}
/>
</>,
{ theme$: theme.theme$ }
);
getNotifications().toasts.addWarning({ title, text });
}
return response;
}

View file

@ -0,0 +1,157 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { estypes } from '@elastic/elasticsearch';
import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
import { themeServiceMock } from '@kbn/core/public/mocks';
import { setNotifications } from '../../services';
import { SearchResponseWarning } from '../types';
import { filterWarnings, handleWarnings } from './handle_warnings';
import * as extract from './extract_warnings';
jest.mock('@kbn/i18n', () => {
return {
i18n: {
translate: (_id: string, { defaultMessage }: { defaultMessage: string }) => defaultMessage,
},
};
});
jest.mock('./extract_warnings', () => ({
extractWarnings: jest.fn(() => []),
}));
const theme = themeServiceMock.createStartContract();
const warnings: SearchResponseWarning[] = [
{
type: 'timed_out' as const,
message: 'Something timed out!',
reason: undefined,
},
{
type: 'shard_failure' as const,
message: 'Some shards failed!',
text: 'test text',
reason: { type: 'illegal_argument_exception', reason: 'Illegal argument! Go to jail!' },
},
{
type: 'shard_failure' as const,
message: 'Some shards failed!',
reason: { type: 'generic_shard_failure' },
},
];
describe('Filtering and showing warnings', () => {
const notifications = notificationServiceMock.createStartContract();
describe('handleWarnings', () => {
const request = { body: {} };
beforeEach(() => {
jest.resetAllMocks();
setNotifications(notifications);
(notifications.toasts.addWarning as jest.Mock).mockReset();
(extract.extractWarnings as jest.Mock).mockImplementation(() => warnings);
});
test('should notify if timed out', () => {
(extract.extractWarnings as jest.Mock).mockImplementation(() => [warnings[0]]);
const response = { rawResponse: { timed_out: true } } as unknown as estypes.SearchResponse;
handleWarnings(request, response, theme);
expect(notifications.toasts.addWarning).toBeCalledTimes(1);
expect(notifications.toasts.addWarning).toBeCalledWith({ title: 'Something timed out!' });
});
test('should notify if shards failed for unknown type/reason', () => {
(extract.extractWarnings as jest.Mock).mockImplementation(() => [warnings[2]]);
const response = {
rawResponse: { _shards: { failed: 1, total: 2, successful: 1, skipped: 1 } },
} as unknown as estypes.SearchResponse;
handleWarnings(request, response, theme);
expect(notifications.toasts.addWarning).toBeCalledTimes(1);
expect(notifications.toasts.addWarning).toBeCalledWith({ title: 'Some shards failed!' });
});
test('should add mount point for shard modal failure button if warning.text is provided', () => {
(extract.extractWarnings as jest.Mock).mockImplementation(() => [warnings[1]]);
const response = {
rawResponse: { _shards: { failed: 1, total: 2, successful: 1, skipped: 1 } },
} as unknown as estypes.SearchResponse;
handleWarnings(request, response, theme);
expect(notifications.toasts.addWarning).toBeCalledTimes(1);
expect(notifications.toasts.addWarning).toBeCalledWith({
title: 'Some shards failed!',
text: expect.any(Function),
});
});
test('should notify once if the response contains multiple failures', () => {
(extract.extractWarnings as jest.Mock).mockImplementation(() => [warnings[1], warnings[2]]);
const response = {
rawResponse: { _shards: { failed: 1, total: 2, successful: 1, skipped: 1 } },
} as unknown as estypes.SearchResponse;
handleWarnings(request, response, theme);
expect(notifications.toasts.addWarning).toBeCalledTimes(1);
expect(notifications.toasts.addWarning).toBeCalledWith({
title: 'Some shards failed!',
text: expect.any(Function),
});
});
test('should notify once if the response contains some unfiltered failures', () => {
const callback = (warning: SearchResponseWarning) =>
warning.reason?.type !== 'generic_shard_failure';
const response = {
rawResponse: { _shards: { failed: 1, total: 2, successful: 1, skipped: 1 } },
} as unknown as estypes.SearchResponse;
handleWarnings(request, response, theme, callback);
expect(notifications.toasts.addWarning).toBeCalledTimes(1);
expect(notifications.toasts.addWarning).toBeCalledWith({ title: 'Some shards failed!' });
});
test('should not notify if the response contains no unfiltered failures', () => {
const callback = () => true;
const response = {
rawResponse: { _shards: { failed: 1, total: 2, successful: 1, skipped: 1 } },
} as unknown as estypes.SearchResponse;
handleWarnings(request, response, theme, callback);
expect(notifications.toasts.addWarning).toBeCalledTimes(0);
});
});
describe('filterWarnings', () => {
const callback = jest.fn();
beforeEach(() => {
callback.mockImplementation(() => {
throw new Error('not initialized');
});
});
it('filters out all', () => {
callback.mockImplementation(() => true);
expect(filterWarnings(warnings, callback)).toEqual([]);
});
it('filters out some', () => {
callback.mockImplementation(
(warning: SearchResponseWarning) => warning.reason?.type !== 'generic_shard_failure'
);
expect(filterWarnings(warnings, callback)).toEqual([warnings[2]]);
});
it('filters out none', () => {
callback.mockImplementation(() => false);
expect(filterWarnings(warnings, callback)).toEqual(warnings);
});
});
});

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { estypes } from '@elastic/elasticsearch';
import { EuiSpacer } from '@elastic/eui';
import { ThemeServiceStart } from '@kbn/core/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import React from 'react';
import { SearchRequest } from '..';
import { getNotifications } from '../../services';
import { ShardFailureOpenModalButton, ShardFailureRequest } from '../../shard_failure_modal';
import {
SearchResponseShardFailureWarning,
SearchResponseWarning,
WarningHandlerCallback,
} from '../types';
import { extractWarnings } from './extract_warnings';
/**
* @internal
* All warnings are expected to come from the same response. Therefore all "text" properties, which contain the
* response, will be the same.
*/
export function handleWarnings(
request: SearchRequest,
response: estypes.SearchResponse,
theme: ThemeServiceStart,
cb?: WarningHandlerCallback
) {
const warnings = extractWarnings(response);
if (warnings.length === 0) {
return;
}
const internal = cb ? filterWarnings(warnings, cb) : warnings;
if (internal.length === 0) {
return;
}
// timeout notification
const [timeout] = internal.filter((w) => w.type === 'timed_out');
if (timeout) {
getNotifications().toasts.addWarning({
title: timeout.message,
});
}
// shard warning failure notification
const shardFailures = internal.filter((w) => w.type === 'shard_failure');
if (shardFailures.length === 0) {
return;
}
const [warning] = shardFailures as SearchResponseShardFailureWarning[];
const title = warning.message;
// if warning message contains text (warning response), show in ShardFailureOpenModalButton
if (warning.text) {
const text = toMountPoint(
<>
{warning.text}
<EuiSpacer size="s" />
<ShardFailureOpenModalButton
request={request as ShardFailureRequest}
response={response}
theme={theme}
title={title}
/>
</>,
{ theme$: theme.theme$ }
);
getNotifications().toasts.addWarning({ title, text });
return;
}
// timeout warning, or shard warning with no failure reason
getNotifications().toasts.addWarning({ title });
}
/**
* @internal
*/
export function filterWarnings(warnings: SearchResponseWarning[], cb: WarningHandlerCallback) {
const unfiltered: SearchResponseWarning[] = [];
// use the consumer's callback as a filter to receive warnings to handle on our side
warnings.forEach((warning) => {
const consumerHandled = cb?.(warning);
if (!consumerHandled) {
unfiltered.push(warning);
}
});
return unfiltered;
}

View file

@ -6,4 +6,4 @@
* Side Public License, v 1.
*/
export { handleResponse } from './handle_response';
export { handleWarnings } from './handle_warnings';

View file

@ -53,7 +53,6 @@ export {
waitUntilNextSessionCompletes$,
} from './session';
export { getEsPreference } from './es_search';
export { handleResponse } from './fetch';
export type { SearchInterceptorDeps } from './search_interceptor';
export { SearchInterceptor } from './search_interceptor';

View file

@ -26,6 +26,7 @@ function createStartContract(): jest.Mocked<ISearchStart> {
aggs: searchAggsStartMock(),
search: jest.fn(),
showError: jest.fn(),
showWarnings: jest.fn(),
session: getSessionServiceMock(),
sessionsClient: getSessionsClientMock(),
searchSource: searchSourceMock.createStartContract(),

View file

@ -6,16 +6,21 @@
* Side Public License, v 1.
*/
import type { MockedKeys } from '@kbn/utility-types-jest';
import { coreMock } from '@kbn/core/public/mocks';
import { CoreSetup, CoreStart } from '@kbn/core/public';
import { SearchService, SearchServiceSetupDependencies } from './search_service';
import { estypes } from '@elastic/elasticsearch';
import { bfetchPluginMock } from '@kbn/bfetch-plugin/public/mocks';
import { managementPluginMock } from '@kbn/management-plugin/public/mocks';
import { screenshotModePluginMock } from '@kbn/screenshot-mode-plugin/public/mocks';
import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
import { CoreSetup, CoreStart } from '@kbn/core/public';
import { coreMock } from '@kbn/core/public/mocks';
import { DataViewsContract } from '@kbn/data-views-plugin/common';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { RequestAdapter } from '@kbn/inspector-plugin/public';
import { managementPluginMock } from '@kbn/management-plugin/public/mocks';
import { screenshotModePluginMock } from '@kbn/screenshot-mode-plugin/public/mocks';
import type { MockedKeys } from '@kbn/utility-types-jest';
import { IInspectorInfo } from '../../common/search/search_source';
import { setNotifications } from '../services';
import { SearchService, SearchServiceSetupDependencies } from './search_service';
import { ISearchStart, WarningHandlerCallback } from './types';
describe('Search service', () => {
let searchService: SearchService;
@ -49,7 +54,8 @@ describe('Search service', () => {
});
describe('start()', () => {
it('exposes proper contract', async () => {
let data: ISearchStart;
beforeEach(() => {
const bfetch = bfetchPluginMock.createSetupContract();
searchService.setup(mockCoreSetup, {
packageInfo: { version: '8' },
@ -57,18 +63,174 @@ describe('Search service', () => {
expressions: { registerFunction: jest.fn(), registerType: jest.fn() },
management: managementPluginMock.createSetupContract(),
} as unknown as SearchServiceSetupDependencies);
const start = searchService.start(mockCoreStart, {
data = searchService.start(mockCoreStart, {
fieldFormats: {} as FieldFormatsStart,
indexPatterns: {} as DataViewsContract,
screenshotMode: screenshotModePluginMock.createStartContract(),
});
expect(start).toHaveProperty('aggs');
expect(start).toHaveProperty('search');
expect(start).toHaveProperty('showError');
expect(start).toHaveProperty('searchSource');
expect(start).toHaveProperty('sessionsClient');
expect(start).toHaveProperty('session');
});
it('exposes proper contract', async () => {
expect(data).toHaveProperty('aggs');
expect(data).toHaveProperty('search');
expect(data).toHaveProperty('showError');
expect(data).toHaveProperty('searchSource');
expect(data).toHaveProperty('sessionsClient');
expect(data).toHaveProperty('session');
});
describe('showWarnings', () => {
const notifications = notificationServiceMock.createStartContract();
const hits = { total: 0, max_score: null, hits: [] };
let failures: estypes.ShardFailure[] = [];
let shards: estypes.ShardStatistics;
let inspector: Required<IInspectorInfo>;
let callback: WarningHandlerCallback;
const getMockInspector = (base: Partial<IInspectorInfo>): Required<IInspectorInfo> =>
({
title: 'test inspector',
id: 'test-inspector-123',
description: '',
...base,
} as Required<IInspectorInfo>);
const getMockResponseWithShards = (mockShards: estypes.ShardStatistics) => ({
json: {
rawResponse: { took: 25, timed_out: false, _shards: mockShards, hits, aggregations: {} },
},
});
beforeEach(() => {
setNotifications(notifications);
notifications.toasts.addWarning.mockClear();
failures = [
{
shard: 0,
index: 'sample-01-rollup',
node: 'VFTFJxpHSdaoiGxJFLSExQ',
reason: {
type: 'illegal_argument_exception',
reason:
'Field [kubernetes.container.memory.available.bytes] of type' +
' [aggregate_metric_double] is not supported for aggregation [percentiles]',
},
},
];
shards = { total: 4, successful: 2, skipped: 0, failed: 2, failures };
const adapter = new RequestAdapter();
inspector = getMockInspector({ adapter });
callback = jest.fn(() => false);
});
it('can show no notifications', () => {
const responder = inspector.adapter.start('request1');
shards = { total: 4, successful: 4, skipped: 0, failed: 0 };
responder.ok(getMockResponseWithShards(shards));
data.showWarnings(inspector.adapter, callback);
expect(notifications.toasts.addWarning).toBeCalledTimes(0);
});
it('can show notifications if no callback is provided', () => {
const responder = inspector.adapter.start('request1');
responder.ok(getMockResponseWithShards(shards));
data.showWarnings(inspector.adapter);
expect(notifications.toasts.addWarning).toBeCalledTimes(1);
expect(notifications.toasts.addWarning).toBeCalledWith({
title: '2 of 4 shards failed',
text: expect.any(Function),
});
});
it("won't show notifications when all warnings are filtered out", () => {
callback = () => true;
const responder = inspector.adapter.start('request1');
responder.ok(getMockResponseWithShards(shards));
data.showWarnings(inspector.adapter, callback);
expect(notifications.toasts.addWarning).toBeCalledTimes(0);
});
it('will show single notification when some warnings are filtered', () => {
callback = (warning) => warning.reason?.type === 'illegal_argument_exception';
shards.failures = [
{
reason: {
type: 'illegal_argument_exception',
reason: 'reason of "illegal_argument_exception"',
},
},
{
reason: {
type: 'other_kind_of_exception',
reason: 'reason of other_kind_of_exception',
},
},
{ reason: { type: 'fatal_warning', reason: 'this is a fatal warning message' } },
] as unknown as estypes.ShardFailure[];
const responder = inspector.adapter.start('request1');
responder.ok(getMockResponseWithShards(shards));
data.showWarnings(inspector.adapter, callback);
expect(notifications.toasts.addWarning).toBeCalledTimes(1);
expect(notifications.toasts.addWarning).toBeCalledWith({
title: '2 of 4 shards failed',
text: expect.any(Function),
});
});
it('can show a timed_out warning', () => {
const responder = inspector.adapter.start('request1');
shards = { total: 4, successful: 4, skipped: 0, failed: 0 };
const response1 = getMockResponseWithShards(shards);
response1.json.rawResponse.timed_out = true;
responder.ok(response1);
data.showWarnings(inspector.adapter, callback);
expect(notifications.toasts.addWarning).toBeCalledTimes(1);
expect(notifications.toasts.addWarning).toBeCalledWith({
title: 'Data might be incomplete because your request timed out',
});
});
it('can show two warnings if response has shard failures and also timed_out', () => {
const responder = inspector.adapter.start('request1');
const response1 = getMockResponseWithShards(shards);
response1.json.rawResponse.timed_out = true;
responder.ok(response1);
data.showWarnings(inspector.adapter, callback);
expect(notifications.toasts.addWarning).toBeCalledTimes(2);
expect(notifications.toasts.addWarning).nthCalledWith(1, {
title: 'Data might be incomplete because your request timed out',
});
expect(notifications.toasts.addWarning).nthCalledWith(2, {
title: '2 of 4 shards failed',
text: expect.any(Function),
});
});
it('will show multiple warnings when multiple responses have shard failures', () => {
const responder1 = inspector.adapter.start('request1');
const responder2 = inspector.adapter.start('request2');
responder1.ok(getMockResponseWithShards(shards));
responder2.ok(getMockResponseWithShards(shards));
data.showWarnings(inspector.adapter, callback);
expect(notifications.toasts.addWarning).toBeCalledTimes(2);
expect(notifications.toasts.addWarning).nthCalledWith(1, {
title: '2 of 4 shards failed',
text: expect.any(Function),
});
expect(notifications.toasts.addWarning).nthCalledWith(2, {
title: '2 of 4 shards failed',
text: expect.any(Function),
});
});
});
});
});

View file

@ -6,6 +6,8 @@
* Side Public License, v 1.
*/
import { estypes } from '@elastic/elasticsearch';
import { BfetchPublicSetup } from '@kbn/bfetch-plugin/public';
import {
CoreSetup,
CoreStart,
@ -13,24 +15,21 @@ import {
PluginInitializerContext,
StartServicesAccessor,
} from '@kbn/core/public';
import { BehaviorSubject } from 'rxjs';
import React from 'react';
import moment from 'moment';
import { BfetchPublicSetup } from '@kbn/bfetch-plugin/public';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import { DataViewsContract } from '@kbn/data-views-plugin/common';
import { ExpressionsSetup } from '@kbn/expressions-plugin/public';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { ScreenshotModePluginStart } from '@kbn/screenshot-mode-plugin/public';
import { ManagementSetup } from '@kbn/management-plugin/public';
import { DataViewsContract } from '@kbn/data-views-plugin/common';
import type { ISearchSetup, ISearchStart } from './types';
import { handleResponse } from './fetch';
import { ScreenshotModePluginStart } from '@kbn/screenshot-mode-plugin/public';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import moment from 'moment';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import {
cidrFunction,
dateRangeFunction,
eqlRawResponse,
esRawResponse,
existsFilterFunction,
extendedBoundsFunction,
@ -51,30 +50,29 @@ import {
rangeFilterFunction,
rangeFunction,
removeFilterFunction,
SearchRequest,
SearchSourceDependencies,
SearchSourceService,
selectFilterFunction,
eqlRawResponse,
SearchSourceSearchOptions,
} from '../../common/search';
import { AggsService } from './aggs';
import { IKibanaSearchResponse, SearchRequest } from '..';
import { ISearchInterceptor, SearchInterceptor } from './search_interceptor';
import { createUsageCollector, SearchUsageCollector } from './collectors';
import { getEsaggs, getEsdsl, getEssql, getEql } from './expressions';
import { ISessionsClient, ISessionService, SessionsClient, SessionService } from './session';
import { ConfigSchema } from '../../config';
import {
getShardDelayBucketAgg,
SHARD_DELAY_AGG_NAME,
} from '../../common/search/aggs/buckets/shard_delay';
import { aggShardDelay } from '../../common/search/aggs/buckets/shard_delay_fn';
import { DataPublicPluginStart, DataStartDependencies } from '../types';
import { ConfigSchema } from '../../config';
import { NowProviderInternalContract } from '../now_provider';
import { DataPublicPluginStart, DataStartDependencies } from '../types';
import { AggsService } from './aggs';
import { createUsageCollector, SearchUsageCollector } from './collectors';
import { getEql, getEsaggs, getEsdsl, getEssql } from './expressions';
import { getKibanaContext } from './expressions/kibana_context';
import { createConnectedSearchSessionIndicator } from './session/session_indicator';
import { handleWarnings } from './fetch/handle_warnings';
import { ISearchInterceptor, SearchInterceptor } from './search_interceptor';
import { ISessionsClient, ISessionService, SessionsClient, SessionService } from './session';
import { registerSearchSessionsMgmt } from './session/sessions_mgmt';
import { createConnectedSearchSessionIndicator } from './session/session_indicator';
import { ISearchSetup, ISearchStart } from './types';
/** @internal */
export interface SearchServiceSetupDependencies {
@ -239,8 +237,13 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
aggs,
getConfig: uiSettings.get.bind(uiSettings),
search,
onResponse: (...args: [SearchRequest, IKibanaSearchResponse, SearchSourceSearchOptions]) =>
handleResponse(...args, theme),
onResponse: (request, response, options) => {
if (!options.disableShardFailureWarning) {
const { rawResponse } = response;
handleWarnings(request.body, rawResponse, theme);
}
return response;
},
};
const config = this.initializerContext.config.get();
@ -268,9 +271,22 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
return {
aggs,
search,
showError: (e: Error) => {
showError: (e) => {
this.searchInterceptor.showError(e);
},
showWarnings: (adapter, cb) => {
adapter?.getRequests().forEach((request) => {
const rawResponse = (
request.response?.json as { rawResponse: estypes.SearchResponse | undefined }
)?.rawResponse;
if (!rawResponse) {
return;
}
handleWarnings(request.json as SearchRequest, rawResponse, theme, cb);
});
},
session: this.sessionService,
sessionsClient: this.sessionsClient,
searchSource: this.searchSourceService.start(indexPatterns, searchSourceDependencies),

View file

@ -6,14 +6,17 @@
* Side Public License, v 1.
*/
import { estypes } from '@elastic/elasticsearch';
import type { PackageInfo } from '@kbn/core/server';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import { DataViewsContract } from '@kbn/data-views-plugin/common';
import { SearchUsageCollector } from './collectors';
import { AggsSetup, AggsSetupDependencies, AggsStartDependencies, AggsStart } from './aggs';
import { RequestAdapter } from '@kbn/inspector-plugin/public';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import { ISearchGeneric, ISearchStartSearchSource } from '../../common/search';
import { AggsSetup, AggsSetupDependencies, AggsStart, AggsStartDependencies } from './aggs';
import { SearchUsageCollector } from './collectors';
import { ISessionsClient, ISessionService } from './session';
export { SEARCH_EVENT_TYPE } from './collectors';
export type { ISearchStartSearchSource, SearchUsageCollector };
/**
@ -51,8 +54,17 @@ export interface ISearchStart {
* {@link ISearchGeneric}
*/
search: ISearchGeneric;
/**
* Show toast for caught error
* @param e Error
*/
showError: (e: Error) => void;
/**
* Show warnings, or customize how they're shown
* @param inspector IInspectorInfo - an inspector object with requests internally collected
* @param cb WarningHandlerCallback - optional callback to intercept warnings
*/
showWarnings: (adapter: RequestAdapter, cb?: WarningHandlerCallback) => void;
/**
* high level search
* {@link ISearchStartSearchSource}
@ -70,8 +82,6 @@ export interface ISearchStart {
sessionsClient: ISessionsClient;
}
export { SEARCH_EVENT_TYPE } from './collectors';
/** @internal */
export interface SearchServiceSetupDependencies {
packageInfo: PackageInfo;
@ -84,3 +94,69 @@ export interface SearchServiceStartDependencies {
fieldFormats: AggsStartDependencies['fieldFormats'];
indexPatterns: DataViewsContract;
}
/**
* A warning object for a search response with internal ES timeouts
* @public
*/
export interface SearchResponseTimeoutWarning {
/**
* type: for sorting out timeout warnings
*/
type: 'timed_out';
/**
* message: human-friendly message
*/
message: string;
/**
* reason: not given for timeout. This exists so that callers do not have to cast when working with shard failure warnings.
*/
reason: undefined;
}
/**
* A warning object for a search response with internal ES shard failures
* @public
*/
export interface SearchResponseShardFailureWarning {
/**
* type: for sorting out shard failure warnings
*/
type: 'shard_failure';
/**
* message: human-friendly message
*/
message: string;
/**
* text: text to show in ShardFailureModal (optional)
*/
text?: string;
/**
* reason: ShardFailureReason from es client
*/
reason: {
/**
* type: failure code from Elasticsearch
*/
type: 'generic_shard_warning' | estypes.ShardFailure['reason']['type'];
/**
* reason: failure reason from Elasticsearch
*/
reason?: estypes.ShardFailure['reason']['reason'];
};
}
/**
* A warning object for a search response with warnings
* @public
*/
export type SearchResponseWarning =
| SearchResponseTimeoutWarning
| SearchResponseShardFailureWarning;
/**
* A callback function which can intercept warnings when passed to {@link showWarnings}. Pass `true` from the
* function to prevent the search service from showing warning notifications by default.
* @public
*/
export type WarningHandlerCallback = (warnings: SearchResponseWarning) => boolean | undefined;

View file

@ -3,7 +3,9 @@
exports[`ShardFailureModal renders matching snapshot given valid properties 1`] = `
<Fragment>
<EuiModalHeader>
<EuiModalHeaderTitle>
<EuiModalHeaderTitle
data-test-subj="shardFailureModalTitle"
>
test
</EuiModalHeaderTitle>
</EuiModalHeader>
@ -37,6 +39,7 @@ exports[`ShardFailureModal renders matching snapshot given valid properties 1`]
]
}
/>,
"data-test-subj": "shardFailuresModalShardButton",
"id": "table",
"name": "Shard failures",
}
@ -69,11 +72,13 @@ exports[`ShardFailureModal renders matching snapshot given valid properties 1`]
]
}
/>,
"data-test-subj": "shardFailuresModalShardButton",
"id": "table",
"name": "Shard failures",
},
Object {
"content": <EuiCodeBlock
data-test-subj="shardsFailedModalRequestBlock"
isCopyable={true}
language="json"
>
@ -93,11 +98,13 @@ exports[`ShardFailureModal renders matching snapshot given valid properties 1`]
"highlight": {}
}
</EuiCodeBlock>,
"data-test-subj": "shardFailuresModalRequestButton",
"id": "json-request",
"name": "Request",
},
Object {
"content": <EuiCodeBlock
data-test-subj="shardsFailedModalResponseBlock"
isCopyable={true}
language="json"
>
@ -131,6 +138,7 @@ exports[`ShardFailureModal renders matching snapshot given valid properties 1`]
}
}
</EuiCodeBlock>,
"data-test-subj": "shardFailuresModalResponseButton",
"id": "json-response",
"name": "Response",
},
@ -174,7 +182,7 @@ exports[`ShardFailureModal renders matching snapshot given valid properties 1`]
<Component />
</EuiCopy>
<EuiButton
data-test-sub="closeShardFailureModal"
data-test-subj="closeShardFailureModal"
fill={true}
onClick={[Function]}
>

View file

@ -61,6 +61,7 @@ export function ShardFailureModal({ request, response, title, onClose }: Props)
}
),
content: <ShardFailureTable failures={failures} />,
['data-test-subj']: 'shardFailuresModalShardButton',
},
{
id: 'json-request',
@ -69,10 +70,11 @@ export function ShardFailureModal({ request, response, title, onClose }: Props)
description: 'Name of the tab displaying the JSON request',
}),
content: (
<EuiCodeBlock language="json" isCopyable>
<EuiCodeBlock language="json" isCopyable data-test-subj="shardsFailedModalRequestBlock">
{requestJSON}
</EuiCodeBlock>
),
['data-test-subj']: 'shardFailuresModalRequestButton',
},
{
id: 'json-response',
@ -81,17 +83,18 @@ export function ShardFailureModal({ request, response, title, onClose }: Props)
description: 'Name of the tab displaying the JSON response',
}),
content: (
<EuiCodeBlock language="json" isCopyable>
<EuiCodeBlock language="json" isCopyable data-test-subj="shardsFailedModalResponseBlock">
{responseJSON}
</EuiCodeBlock>
),
['data-test-subj']: 'shardFailuresModalResponseButton',
},
];
return (
<React.Fragment>
<EuiModalHeader>
<EuiModalHeaderTitle>{title}</EuiModalHeaderTitle>
<EuiModalHeaderTitle data-test-subj="shardFailureModalTitle">{title}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiTabbedContent tabs={tabs} initialSelectedTab={tabs[0]} autoFocus="selected" />
@ -107,7 +110,7 @@ export function ShardFailureModal({ request, response, title, onClose }: Props)
</EuiButtonEmpty>
)}
</EuiCopy>
<EuiButton onClick={() => onClose()} fill data-test-sub="closeShardFailureModal">
<EuiButton onClick={() => onClose()} fill data-test-subj="closeShardFailureModal">
<FormattedMessage
id="data.search.searchSource.fetch.shardsFailedModal.close"
defaultMessage="Close"

View file

@ -7,7 +7,7 @@
*/
import type { KibanaExecutionContext } from '@kbn/core/public';
import type { Adapters } from '@kbn/inspector-plugin/common';
import { KibanaContext, handleResponse } from '@kbn/data-plugin/public';
import { KibanaContext } from '@kbn/data-plugin/public';
import { getTimezone } from './application/lib/get_timezone';
import { getUISettings, getDataStart, getCoreStart } from './services';
import { ROUTES, UI_SETTINGS } from '../common/constants';
@ -37,7 +37,6 @@ export const metricsRequestHandler = async ({
if (!expressionAbortSignal.aborted) {
const config = getUISettings();
const data = getDataStart();
const theme = getCoreStart().theme;
const abortController = new AbortController();
const expressionAbortHandler = function () {
abortController.abort();
@ -84,10 +83,14 @@ export const metricsRequestHandler = async ({
inspectorAdapters?.requests
?.start(query.label ?? key, { searchSessionId })
.json(query.body)
.ok({ time: query.time });
.ok({ time: query.time, json: { rawResponse: query.response } });
if (query.response && config.get(UI_SETTINGS.ALLOW_CHECKING_FOR_FAILED_SHARDS)) {
handleResponse({ body: query.body }, { rawResponse: query.response }, {}, theme);
if (
query.response &&
inspectorAdapters?.requests &&
config.get(UI_SETTINGS.ALLOW_CHECKING_FOR_FAILED_SHARDS)
) {
data.search.showWarnings(inspectorAdapters.requests);
}
});

View file

@ -17,7 +17,7 @@ export class IndexPatternsService extends FtrService {
*/
async create(
indexPattern: { title: string; timeFieldName?: string },
{ override = false }: { override: boolean },
{ override } = { override: false },
spaceId = ''
): Promise<DataViewSpec> {
const response = await this.kibanaServer.request<{

View file

@ -33,6 +33,7 @@ export default async function ({ readConfigFile }) {
require.resolve('./data_view_field_editor_example'),
require.resolve('./field_formats'),
require.resolve('./partial_results'),
require.resolve('./search'),
],
services: {
...functionalConfig.get('services'),

View file

@ -0,0 +1,16 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { FtrProviderContext } from '../../functional/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function ({ loadTestFile }: FtrProviderContext) {
describe('Search examples', () => {
loadTestFile(require.resolve('./warnings'));
});
}

View file

@ -0,0 +1,193 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { asyncForEach } from '@kbn/std';
import { FtrProviderContext } from '../../functional/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects(['common', 'timePicker']);
const testSubjects = getService('testSubjects');
const find = getService('find');
const es = getService('es');
const log = getService('log');
const indexPatterns = getService('indexPatterns');
const comboBox = getService('comboBox');
const kibanaServer = getService('kibanaServer');
const esArchiver = getService('esArchiver');
describe('handling warnings with search source fetch', function () {
const dataViewTitle = 'sample-01,sample-01-rollup';
const fromTime = 'Jun 17, 2022 @ 00:00:00.000';
const toTime = 'Jun 23, 2022 @ 00:00:00.000';
const testArchive = 'test/functional/fixtures/es_archiver/search/downsampled';
const testIndex = 'sample-01';
const testRollupIndex = 'sample-01-rollup';
const testRollupField = 'kubernetes.container.memory.usage.bytes';
const toastsSelector = '[data-test-subj=globalToastList] [data-test-subj=euiToastHeader]';
const shardFailureType = 'unsupported_aggregation_on_rollup_index';
const shardFailureReason = `Field [${testRollupField}] of type [aggregate_metric_double] is not supported for aggregation [percentiles]`;
const getTestJson = async (tabTestSubj: string, codeTestSubj: string) => {
log.info(`switch to ${tabTestSubj} tab...`);
await testSubjects.click(tabTestSubj);
const block = await testSubjects.find(codeTestSubj);
const testText = (await block.getVisibleText()).trim();
return testText && JSON.parse(testText);
};
before(async () => {
// create rollup data
log.info(`loading ${testIndex} index...`);
await esArchiver.loadIfNeeded(testArchive);
log.info(`add write block to ${testIndex} index...`);
await es.indices.addBlock({ index: testIndex, block: 'write' });
try {
log.info(`rolling up ${testIndex} index...`);
await es.rollup.rollup({
index: testIndex,
rollup_index: testRollupIndex,
config: { fixed_interval: '1h' },
});
} catch (err) {
log.info(`ignoring resource_already_exists_exception...`);
if (!err.message.match(/resource_already_exists_exception/)) {
throw err;
}
}
log.info(`creating ${dataViewTitle} data view...`);
await indexPatterns.create(
{
title: dataViewTitle,
timeFieldName: '@timestamp',
},
{ override: true }
);
await kibanaServer.uiSettings.update({
'dateFormat:tz': 'UTC',
defaultIndex: '0ae0bc7a-e4ca-405c-ab67-f2b5913f2a51',
'timepicker:timeDefaults': '{ "from": "now-1y", "to": "now" }',
});
});
after(async () => {
await es.indices.delete({ index: [testIndex, testRollupIndex] });
await kibanaServer.savedObjects.cleanStandardList();
await kibanaServer.uiSettings.replace({});
});
beforeEach(async () => {
// reload the page to clear toasts from previous test
await PageObjects.common.navigateToApp('searchExamples');
await comboBox.setCustom('dataViewSelector', dataViewTitle);
await comboBox.set('searchMetricField', testRollupField);
await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime);
});
it('shows shard failure warning notifications by default', async () => {
await testSubjects.click('searchSourceWithOther');
// toasts
const toasts = await find.allByCssSelector(toastsSelector);
expect(toasts.length).to.be(3);
const expects = ['2 of 4 shards failed', '2 of 4 shards failed', 'Query result']; // BUG: there are 2 shards failed toast notifications
await asyncForEach(toasts, async (t, index) => {
expect(await t.getVisibleText()).to.eql(expects[index]);
});
// click "see full error" button in the toast
const [openShardModalButton] = await testSubjects.findAll('openShardFailureModalBtn');
await openShardModalButton.click();
const modalHeader = await testSubjects.find('shardFailureModalTitle');
expect(await modalHeader.getVisibleText()).to.be('2 of 4 shards failed');
// request
await testSubjects.click('shardFailuresModalRequestButton');
const requestBlock = await testSubjects.find('shardsFailedModalRequestBlock');
expect(await requestBlock.getVisibleText()).to.contain(testRollupField);
// response
await testSubjects.click('shardFailuresModalResponseButton');
const responseBlock = await testSubjects.find('shardsFailedModalResponseBlock');
expect(await responseBlock.getVisibleText()).to.contain(shardFailureReason);
// close things
await testSubjects.click('closeShardFailureModal');
await PageObjects.common.clearAllToasts();
// response tab
const response = await getTestJson('responseTab', 'responseCodeBlock');
expect(response._shards.total).to.be(4);
expect(response._shards.successful).to.be(2);
expect(response._shards.skipped).to.be(0);
expect(response._shards.failed).to.be(2);
expect(response._shards.failures.length).to.equal(1);
expect(response._shards.failures[0].index).to.equal(testRollupIndex);
expect(response._shards.failures[0].reason.type).to.equal(shardFailureType);
expect(response._shards.failures[0].reason.reason).to.equal(shardFailureReason);
// warnings tab
const warnings = await getTestJson('warningsTab', 'warningsCodeBlock');
expect(warnings).to.eql([]);
});
it('able to handle shard failure warnings and prevent default notifications', async () => {
await testSubjects.click('searchSourceWithoutOther');
// toasts
const toasts = await find.allByCssSelector(toastsSelector);
expect(toasts.length).to.be(2);
const expects = ['2 of 4 shards failed', 'Query result'];
await asyncForEach(toasts, async (t, index) => {
expect(await t.getVisibleText()).to.eql(expects[index]);
});
// click "see full error" button in the toast
const [openShardModalButton] = await testSubjects.findAll('openShardFailureModalBtn');
await openShardModalButton.click();
const modalHeader = await testSubjects.find('shardFailureModalTitle');
expect(await modalHeader.getVisibleText()).to.be('2 of 4 shards failed');
// request
await testSubjects.click('shardFailuresModalRequestButton');
const requestBlock = await testSubjects.find('shardsFailedModalRequestBlock');
expect(await requestBlock.getVisibleText()).to.contain(testRollupField);
// response
await testSubjects.click('shardFailuresModalResponseButton');
const responseBlock = await testSubjects.find('shardsFailedModalResponseBlock');
expect(await responseBlock.getVisibleText()).to.contain(shardFailureReason);
// close things
await testSubjects.click('closeShardFailureModal');
await PageObjects.common.clearAllToasts();
// response tab
const response = await getTestJson('responseTab', 'responseCodeBlock');
expect(response._shards.total).to.be(4);
expect(response._shards.successful).to.be(2);
expect(response._shards.skipped).to.be(0);
expect(response._shards.failed).to.be(2);
expect(response._shards.failures.length).to.equal(1);
expect(response._shards.failures[0].index).to.equal(testRollupIndex);
expect(response._shards.failures[0].reason.type).to.equal(shardFailureType);
expect(response._shards.failures[0].reason.reason).to.equal(shardFailureReason);
// warnings tab
const warnings = await getTestJson('warningsTab', 'warningsCodeBlock');
expect(warnings).to.eql([
{
type: 'shard_failure',
message: '2 of 4 shards failed',
reason: { reason: shardFailureReason, type: shardFailureType },
text: 'The data you are seeing might be incomplete or wrong.',
},
]);
});
});
}

View file

@ -0,0 +1,155 @@
{
"type": "index",
"value": {
"aliases": {
},
"index": "sample-01",
"mappings": {
"_data_stream_timestamp": {
"enabled": true
},
"properties": {
"@timestamp": {
"type": "date"
},
"kubernetes": {
"properties": {
"container": {
"properties": {
"cpu": {
"properties": {
"usage": {
"properties": {
"core": {
"properties": {
"ns": {
"type": "long"
}
}
},
"limit": {
"properties": {
"pct": {
"type": "float"
}
}
},
"nanocores": {
"time_series_metric": "gauge",
"type": "long"
},
"node": {
"properties": {
"pct": {
"type": "float"
}
}
}
}
}
}
},
"memory": {
"properties": {
"available": {
"properties": {
"bytes": {
"time_series_metric": "gauge",
"type": "long"
}
}
},
"majorpagefaults": {
"type": "long"
},
"pagefaults": {
"time_series_metric": "gauge",
"type": "long"
},
"rss": {
"properties": {
"bytes": {
"time_series_metric": "gauge",
"type": "long"
}
}
},
"usage": {
"properties": {
"bytes": {
"time_series_metric": "gauge",
"type": "long"
},
"limit": {
"properties": {
"pct": {
"type": "float"
}
}
},
"node": {
"properties": {
"pct": {
"type": "float"
}
}
}
}
},
"workingset": {
"properties": {
"bytes": {
"time_series_metric": "gauge",
"type": "long"
}
}
}
}
},
"name": {
"type": "keyword"
},
"start_time": {
"type": "date"
}
}
},
"host": {
"time_series_dimension": true,
"type": "keyword"
},
"namespace": {
"time_series_dimension": true,
"type": "keyword"
},
"node": {
"time_series_dimension": true,
"type": "keyword"
},
"pod": {
"time_series_dimension": true,
"type": "keyword"
}
}
}
}
},
"settings": {
"index": {
"mode": "time_series",
"number_of_replicas": "0",
"number_of_shards": "2",
"routing_path": [
"kubernetes.namespace",
"kubernetes.host",
"kubernetes.node",
"kubernetes.pod"
],
"time_series": {
"end_time": "2022-06-30T23:59:59Z",
"start_time": "2022-06-10T00:00:00Z"
}
}
}
}
}

View file

@ -1,48 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Lens App renders the editor frame 1`] = `
Array [
Array [
Object {
"indexPatternService": Object {
"ensureIndexPattern": [Function],
"getDefaultIndex": [Function],
"loadIndexPatternRefs": [Function],
"loadIndexPatterns": [Function],
"refreshExistingFields": [Function],
"replaceDataViewId": [Function],
"updateDataViewsState": [Function],
},
"lensInspector": Object {
"adapters": Object {
"expression": ExpressionsInspectorAdapter {
"_ast": Object {},
"_events": Object {},
"_eventsCount": 0,
"_maxListeners": undefined,
Symbol(kCapture): false,
},
"requests": RequestAdapter {
"_events": Object {},
"_eventsCount": 0,
"_maxListeners": undefined,
"requests": Map {},
Symbol(kCapture): false,
},
"tables": TablesAdapter {
"_events": Object {},
"_eventsCount": 0,
"_maxListeners": undefined,
"_tables": Object {},
Symbol(kCapture): false,
},
},
"close": [MockFunction],
"inspect": [MockFunction],
},
"showNoDataPopover": [Function],
},
Object {},
],
]
`;

View file

@ -141,7 +141,22 @@ describe('Lens App', () => {
it('renders the editor frame', async () => {
const { frame } = await mountWith({});
expect(frame.EditorFrameContainer.mock.calls).toMatchSnapshot();
expect(frame.EditorFrameContainer).toHaveBeenLastCalledWith(
{
indexPatternService: expect.any(Object),
lensInspector: {
adapters: {
expression: expect.any(Object),
requests: expect.any(Object),
tables: expect.any(Object),
},
close: expect.any(Function),
inspect: expect.any(Function),
},
showNoDataPopover: expect.any(Function),
},
{}
);
});
it('updates global filters with store state', async () => {

View file

@ -29,7 +29,7 @@ import {
TimelineTabs,
} from '../../../../common/types/timeline';
import type { ISearchStart } from '@kbn/data-plugin/public';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { searchServiceMock } from '@kbn/data-plugin/public/search/mocks';
import { getTimelineTemplate } from '../../../timelines/containers/api';
import { defaultHeaders } from '../../../timelines/components/timeline/body/column_headers/default_headers';
import { KibanaServices } from '../../../common/lib/kibana';
@ -239,11 +239,8 @@ describe('alert actions', () => {
});
searchStrategyClient = {
...dataPluginMock.createStartContract().search,
aggs: {} as ISearchStart['aggs'],
showError: jest.fn(),
...searchServiceMock.createStartContract(),
search: jest.fn().mockImplementation(() => of({ data: mockTimelineDetails })),
searchSource: {} as ISearchStart['searchSource'],
};
(getTimelineTemplate as jest.Mock).mockResolvedValue(mockTimelineResult);