diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.md index c7046902dac7..73261cd49d6d 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.md @@ -21,4 +21,5 @@ export interface IKibanaSearchResponse | [loaded](./kibana-plugin-plugins-data-public.ikibanasearchresponse.loaded.md) | number | If relevant to the search strategy, return a loaded number that represents how progress is indicated. | | [rawResponse](./kibana-plugin-plugins-data-public.ikibanasearchresponse.rawresponse.md) | RawResponse | The raw response returned by the internal search method (usually the raw ES response) | | [total](./kibana-plugin-plugins-data-public.ikibanasearchresponse.total.md) | number | If relevant to the search strategy, return a total number that represents how progress is indicated. | +| [warning](./kibana-plugin-plugins-data-public.ikibanasearchresponse.warning.md) | string | Optional warnings that should be surfaced to the end user | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.warning.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.warning.md new file mode 100644 index 000000000000..cc0b8e2bea56 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.warning.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IKibanaSearchResponse](./kibana-plugin-plugins-data-public.ikibanasearchresponse.md) > [warning](./kibana-plugin-plugins-data-public.ikibanasearchresponse.warning.md) + +## IKibanaSearchResponse.warning property + +Optional warnings that should be surfaced to the end user + +Signature: + +```typescript +warning?: string; +``` diff --git a/examples/search_examples/public/search/app.tsx b/examples/search_examples/public/search/app.tsx index 06f9426b4965..bfb41160ae96 100644 --- a/examples/search_examples/public/search/app.tsx +++ b/examples/search_examples/public/search/app.tsx @@ -131,12 +131,46 @@ export const SearchExamplesApp = ({ setSelectedNumericField(fields?.length ? getNumeric(fields)[0] : null); }, [fields]); - const doAsyncSearch = async (strategy?: string, sessionId?: string) => { + const doAsyncSearch = async ( + strategy?: string, + sessionId?: string, + addWarning: boolean = false, + addError: boolean = false + ) => { if (!indexPattern || !selectedNumericField) return; // Construct the query portion of the search request const query = data.query.getEsQuery(indexPattern); + if (addWarning) { + query.bool.must.push({ + // @ts-ignore + error_query: { + indices: [ + { + name: indexPattern.title, + error_type: 'warning', + message: 'Watch out!', + }, + ], + }, + }); + } + if (addError) { + query.bool.must.push({ + // @ts-ignore + error_query: { + indices: [ + { + name: indexPattern.title, + error_type: 'exception', + message: 'Watch out!', + }, + ], + }, + }); + } + // Construct the aggregations portion of the search request by using the `data.search.aggs` service. const aggs = [{ type: 'avg', params: { field: selectedNumericField!.name } }]; const aggsDsl = data.search.aggs.createAggConfigs(indexPattern, aggs).toDsl(); @@ -193,14 +227,23 @@ export const SearchExamplesApp = ({ } ); searchSubscription$.unsubscribe(); + if (res.warning) { + notifications.toasts.addWarning({ + title: 'Warning', + text: mountReactNode(res.warning), + }); + } } else if (isErrorResponse(res)) { // TODO: Make response error status clearer - notifications.toasts.addWarning('An error has occurred'); + notifications.toasts.addDanger('An error has occurred'); searchSubscription$.unsubscribe(); } }, - error: () => { - notifications.toasts.addDanger('Failed to run search'); + error: (e) => { + notifications.toasts.addDanger({ + title: 'Failed to run search', + text: e.message, + }); }, }); }; @@ -270,6 +313,14 @@ export const SearchExamplesApp = ({ doAsyncSearch('myStrategy'); }; + const onWarningSearchClickHandler = () => { + doAsyncSearch(undefined, undefined, true); + }; + + const onErrorSearchClickHandler = () => { + doAsyncSearch(undefined, undefined, false, true); + }; + const onPartialResultsClickHandler = () => { setSelectedTab(1); const req = { @@ -299,8 +350,11 @@ export const SearchExamplesApp = ({ searchSubscription$.unsubscribe(); } }, - error: () => { - notifications.toasts.addDanger('Failed to run search'); + error: (e) => { + notifications.toasts.addDanger({ + title: 'Failed to run search', + text: e.message, + }); }, }); }; @@ -530,6 +584,38 @@ export const SearchExamplesApp = ({ + +

Handling errors & warnings

+
+ + When fetching data from Elasticsearch, there are several different ways warnings and + errors may be returned. In general, it is recommended to surface these in the UX. + + + + + + + + + +

Handling partial results

diff --git a/src/plugins/data/common/search/types.ts b/src/plugins/data/common/search/types.ts index cffba6203dfe..68a25d4c4d69 100644 --- a/src/plugins/data/common/search/types.ts +++ b/src/plugins/data/common/search/types.ts @@ -70,6 +70,11 @@ export interface IKibanaSearchResponse { */ isRestored?: boolean; + /** + * Optional warnings that should be surfaced to the end user + */ + warning?: string; + /** * The raw response returned by the internal search method (usually the raw ES response) */ diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 1fb78900f9e3..2f3df32cc6ff 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1149,6 +1149,7 @@ export interface IKibanaSearchResponse { loaded?: number; rawResponse: RawResponse; total?: number; + warning?: string; } // Warning: (ae-forgotten-export) The symbol "MetricAggType" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/fetch/handle_response.tsx b/src/plugins/data/public/search/fetch/handle_response.tsx index 58e4da6b95fa..f4acebfb3606 100644 --- a/src/plugins/data/public/search/fetch/handle_response.tsx +++ b/src/plugins/data/public/search/fetch/handle_response.tsx @@ -16,7 +16,18 @@ import { getNotifications } from '../../services'; import { SearchRequest } from '..'; export function handleResponse(request: SearchRequest, response: IKibanaSearchResponse) { - const { rawResponse } = response; + const { rawResponse, warning } = response; + if (warning) { + getNotifications().toasts.addWarning({ + title: i18n.translate('data.search.searchSource.fetch.warningMessage', { + defaultMessage: 'Warning: {warning}', + values: { + warning, + }, + }), + }); + } + if (rawResponse.timed_out) { getNotifications().toasts.addWarning({ title: i18n.translate('data.search.searchSource.fetch.requestTimedOutNotificationMessage', { diff --git a/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts b/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts index eb39417ac535..75a4ddf05141 100644 --- a/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts +++ b/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts @@ -71,12 +71,14 @@ export const enhancedEsSearchStrategyProvider = ( const promise = id ? client.asyncSearch.get({ ...params, id }) : client.asyncSearch.submit(params); - const { body } = await shimAbortSignal(promise, options.abortSignal); + const { body, headers } = await shimAbortSignal(promise, options.abortSignal); + const response = shimHitsTotal(body.response, options); return toAsyncKibanaSearchResponse( // @ts-expect-error @elastic/elasticsearch start_time_in_millis expected to be number - { ...body, response } + { ...body, response }, + headers?.warning ); }; diff --git a/src/plugins/data/server/search/strategies/ese_search/response_utils.ts b/src/plugins/data/server/search/strategies/ese_search/response_utils.ts index ae3d258e2205..0a92c95dac61 100644 --- a/src/plugins/data/server/search/strategies/ese_search/response_utils.ts +++ b/src/plugins/data/server/search/strategies/ese_search/response_utils.ts @@ -12,12 +12,13 @@ import { getTotalLoaded } from '../es_search'; /** * Get the Kibana representation of an async search response (see `IKibanaSearchResponse`). */ -export function toAsyncKibanaSearchResponse(response: AsyncSearchResponse) { +export function toAsyncKibanaSearchResponse(response: AsyncSearchResponse, warning?: string) { return { id: response.id, rawResponse: response.response, isPartial: response.is_partial, isRunning: response.is_running, + ...(warning ? { warning } : {}), ...getTotalLoaded(response.response), }; } diff --git a/x-pack/test/examples/search_examples/search_example.ts b/x-pack/test/examples/search_examples/search_example.ts index c841b595ed11..fb3cef4055e3 100644 --- a/x-pack/test/examples/search_examples/search_example.ts +++ b/x-pack/test/examples/search_examples/search_example.ts @@ -5,6 +5,7 @@ * 2.0. */ +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../functional/ftr_provider_context'; // eslint-disable-next-line import/no-default-export @@ -13,6 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'timePicker']); const retry = getService('retry'); const comboBox = getService('comboBox'); + const toasts = getService('toasts'); describe('Search session example', () => { const appId = 'searchExamples'; @@ -28,6 +30,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); }); + beforeEach(async () => { + await toasts.dismissAllToasts(); + await retry.waitFor('toasts gone', async () => { + return (await toasts.getToastCount()) === 0; + }); + }); + it('should have an other bucket', async () => { await testSubjects.click('searchSourceWithOther'); await testSubjects.click('responseTab'); @@ -53,5 +62,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return buckets.length === 2; }); }); + + it('should handle warnings', async () => { + await testSubjects.click('searchWithWarning'); + await retry.waitFor('', async () => { + const toastCount = await toasts.getToastCount(); + return toastCount > 1; + }); + const warningToast = await toasts.getToastElement(2); + const textEl = await warningToast.findByClassName('euiToastBody'); + const text: string = await textEl.getVisibleText(); + expect(text).to.contain('Watch out!'); + }); }); }