From 96b1f27b455f307b754ae392e3407bd33e3a4f7a Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Mon, 28 Mar 2022 12:01:08 -0500 Subject: [PATCH] [ML] Fix Index data visualizer reaching Elasticsearch rate request limits (#124898) * Add pagination fetching, need fix on table sorting callback * Reset fetch state * Fix sorting * Add max concurrent reqs to overall stats * Add loading spinners, clean up * Fix onTableChange * [ML] Fix field stats missing * Fix loading when show empty fields on * Fix loading spinner for numeric fields * Change switch map to map Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/types/field_request_config.ts | 1 + .../fields_stats_grid/filter_fields.ts | 16 +- .../data_visualizer_stats_table.tsx | 10 + .../constants/index_data_visualizer_viewer.ts | 2 + .../grid_embeddable/grid_embeddable.tsx | 1 + .../hooks/use_data_visualizer_grid_data.ts | 82 ++++---- .../hooks/use_field_stats.ts | 117 +++++++---- .../hooks/use_overall_stats.ts | 189 ++++++++++-------- .../search_strategy/requests/overall_stats.ts | 7 + 9 files changed, 256 insertions(+), 169 deletions(-) diff --git a/x-pack/plugins/data_visualizer/common/types/field_request_config.ts b/x-pack/plugins/data_visualizer/common/types/field_request_config.ts index f0ea7079bf750a..e15e39ffb46de4 100644 --- a/x-pack/plugins/data_visualizer/common/types/field_request_config.ts +++ b/x-pack/plugins/data_visualizer/common/types/field_request_config.ts @@ -17,6 +17,7 @@ export interface FieldRequestConfig { fieldName: string; type: JobFieldType; cardinality: number; + existsInDocs: boolean; } export interface DocumentCountBuckets { diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/fields_stats_grid/filter_fields.ts b/x-pack/plugins/data_visualizer/public/application/common/components/fields_stats_grid/filter_fields.ts index de97b6007d877a..145a8fa5f8867d 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/fields_stats_grid/filter_fields.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/fields_stats_grid/filter_fields.ts @@ -6,15 +6,15 @@ */ import { JOB_FIELD_TYPES } from '../../../../../common/constants'; -import type { - FileBasedFieldVisConfig, - FileBasedUnknownFieldVisConfig, -} from '../../../../../common/types/field_vis_config'; -export function filterFields( - fields: Array, - visibleFieldNames: string[], - visibleFieldTypes: string[] +interface CommonFieldConfig { + type: string; + fieldName?: string; +} +export function filterFields( + fields: T[], + visibleFieldNames: string[] | undefined, + visibleFieldTypes: string[] | undefined ) { let items = fields; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx index cc9cd075c76153..5d4c4de8ca9015 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx @@ -19,6 +19,7 @@ import { LEFT_ALIGNMENT, RIGHT_ALIGNMENT, EuiResizeObserver, + EuiLoadingSpinner, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { EuiTableComputedColumnType } from '@elastic/eui/src/components/basic_table/table_types'; @@ -279,6 +280,15 @@ export const DataVisualizerTable = ({ ), render: (item: DataVisualizerTableItem) => { if (item === undefined || showDistributions === false) return null; + + if ('loading' in item && item.loading === true) { + return ( + + + + ); + } + if ( (item.type === JOB_FIELD_TYPES.KEYWORD || item.type === JOB_FIELD_TYPES.IP) && item.stats?.topValues !== undefined diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/constants/index_data_visualizer_viewer.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/constants/index_data_visualizer_viewer.ts index cd12706d0bc9ba..c1b314a19cf378 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/constants/index_data_visualizer_viewer.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/constants/index_data_visualizer_viewer.ts @@ -6,3 +6,5 @@ */ export const DATA_VISUALIZER_INDEX_VIEWER = 'DATA_VISUALIZER_INDEX_VIEWER'; + +export const MAX_CONCURRENT_REQUESTS = 10; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx index 8097d400c3b71f..c44932470c7e8a 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx @@ -84,6 +84,7 @@ export const EmbeddableWrapper = ({ }, [dataVisualizerListState, onOutputChange] ); + const { configs, searchQueryLanguage, searchString, extendedColumns, progress, setLastRefresh } = useDataVisualizerGridData(input, dataVisualizerListState); diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts index bbf088c53d94ca..7c821f698d8df3 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts @@ -53,7 +53,6 @@ export const useDataVisualizerGridData = ( const { services } = useDataVisualizerKibana(); const { uiSettings, data } = services; const { samplerShardSize, visibleFieldTypes, showEmptyFields } = dataVisualizerListState; - const dataVisualizerListStateRef = useRef(dataVisualizerListState); const [lastRefresh, setLastRefresh] = useState(0); const searchSessionId = input.sessionId; @@ -227,11 +226,11 @@ export const useDataVisualizerGridData = ( if (overallStatsProgress.loaded < 100) return; const existMetricFields = metricConfigs .map((config) => { - if (config.existsInDocs === false) return; return { fieldName: config.fieldName, type: config.type, cardinality: config.stats?.cardinality ?? 0, + existsInDocs: config.existsInDocs, }; }) .filter((c) => c !== undefined) as FieldRequestConfig[]; @@ -240,11 +239,11 @@ export const useDataVisualizerGridData = ( // Top values will be obtained on a sample if cardinality > 100000. const existNonMetricFields: FieldRequestConfig[] = nonMetricConfigs .map((config) => { - if (config.existsInDocs === false) return; return { fieldName: config.fieldName, type: config.type, cardinality: config.stats?.cardinality ?? 0, + existsInDocs: config.existsInDocs, }; }) .filter((c) => c !== undefined) as FieldRequestConfig[]; @@ -255,7 +254,7 @@ export const useDataVisualizerGridData = ( const strategyResponse = useFieldStatsSearchStrategy( fieldStatsRequest, configsWithoutStats, - dataVisualizerListStateRef.current + dataVisualizerListState ); const combinedProgress = useMemo( @@ -325,7 +324,7 @@ export const useDataVisualizerGridData = ( ...fieldData, fieldFormat: currentDataView.getFormatterForField(field), type: JOB_FIELD_TYPES.NUMBER, - loading: true, + loading: fieldData?.existsInDocs ?? true, aggregatable: true, deletable: field.runtimeField !== undefined, }; @@ -436,41 +435,46 @@ export const useDataVisualizerGridData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [overallStats, showEmptyFields]); - const configs = useMemo(() => { - const fieldStats = strategyResponse.fieldStats; - let combinedConfigs = [...nonMetricConfigs, ...metricConfigs]; - if (visibleFieldTypes && visibleFieldTypes.length > 0) { - combinedConfigs = combinedConfigs.filter( - (config) => visibleFieldTypes.findIndex((field) => field === config.type) > -1 - ); - } - if (visibleFieldNames && visibleFieldNames.length > 0) { - combinedConfigs = combinedConfigs.filter( - (config) => visibleFieldNames.findIndex((field) => field === config.fieldName) > -1 - ); - } - - if (fieldStats) { - combinedConfigs = combinedConfigs.map((c) => { - const loadedFullStats = fieldStats.get(c.fieldName) ?? {}; - return loadedFullStats - ? { - ...c, - loading: false, - stats: { ...c.stats, ...loadedFullStats }, - } - : c; - }); - } + const configs = useMemo( + () => { + const fieldStats = strategyResponse.fieldStats; + let combinedConfigs = [...nonMetricConfigs, ...metricConfigs]; + if (visibleFieldTypes && visibleFieldTypes.length > 0) { + combinedConfigs = combinedConfigs.filter( + (config) => visibleFieldTypes.findIndex((field) => field === config.type) > -1 + ); + } + if (visibleFieldNames && visibleFieldNames.length > 0) { + combinedConfigs = combinedConfigs.filter( + (config) => visibleFieldNames.findIndex((field) => field === config.fieldName) > -1 + ); + } - return combinedConfigs; - }, [ - nonMetricConfigs, - metricConfigs, - visibleFieldTypes, - visibleFieldNames, - strategyResponse.fieldStats, - ]); + if (fieldStats) { + combinedConfigs = combinedConfigs.map((c) => { + const loadedFullStats = fieldStats.get(c.fieldName) ?? {}; + return loadedFullStats + ? { + ...c, + loading: false, + stats: { ...c.stats, ...loadedFullStats }, + } + : c; + }); + } + return combinedConfigs; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + nonMetricConfigs, + metricConfigs, + visibleFieldTypes, + visibleFieldNames, + strategyResponse.progress.loaded, + dataVisualizerListState.pageIndex, + dataVisualizerListState.pageSize, + ] + ); // Some actions open up fly-out or popup // This variable is used to keep track of them and clean up when unmounting diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_field_stats.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_field_stats.ts index 19eaf8be68327a..750c1d417e84dd 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_field_stats.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_field_stats.ts @@ -6,10 +6,11 @@ */ import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; -import { combineLatest, Observable, Subject, Subscription } from 'rxjs'; +import { combineLatest, from, Observable, Subject, Subscription } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { last, cloneDeep } from 'lodash'; -import { switchMap } from 'rxjs/operators'; +import { mergeMap, switchMap } from 'rxjs/operators'; +import { Comparators } from '@elastic/eui'; import type { DataStatsFetchProgress, FieldStatsSearchStrategyReturnBase, @@ -29,6 +30,9 @@ import { getInitialProgress, getReducer } from '../progress_utils'; import { MAX_EXAMPLES_DEFAULT } from '../search_strategy/requests/constants'; import type { ISearchOptions } from '../../../../../../../src/plugins/data/common'; import { getFieldsStats } from '../search_strategy/requests/get_fields_stats'; +import { MAX_CONCURRENT_REQUESTS } from '../constants/index_data_visualizer_viewer'; +import { filterFields } from '../../common/components/fields_stats_grid/filter_fields'; + interface FieldStatsParams { metricConfigs: FieldRequestConfig[]; nonMetricConfigs: FieldRequestConfig[]; @@ -61,7 +65,7 @@ const createBatchedRequests = (fields: Field[], maxBatchSize = 10) => { export function useFieldStatsSearchStrategy( searchStrategyParams: OverallStatsSearchStrategyParams | undefined, fieldStatsParams: FieldStatsParams | undefined, - initialDataVisualizerListState: DataVisualizerIndexBasedAppState + dataVisualizerListState: DataVisualizerIndexBasedAppState ): FieldStatsSearchStrategyReturnBase { const { services: { @@ -106,19 +110,41 @@ export function useFieldStatsSearchStrategy( return; } - const { sortField, sortDirection } = initialDataVisualizerListState; + const { sortField, sortDirection } = dataVisualizerListState; /** * Sort the list of fields by the initial sort field and sort direction * Then divide into chunks by the initial page size */ - let sortedConfigs = [...fieldStatsParams.metricConfigs, ...fieldStatsParams.nonMetricConfigs]; + const itemsSorter = Comparators.property( + sortField as string, + Comparators.default(sortDirection as 'asc' | 'desc' | undefined) + ); - if (sortField === 'fieldName' || sortField === 'type') { - sortedConfigs = sortedConfigs.sort((a, b) => a[sortField].localeCompare(b[sortField])); - } - if (sortDirection === 'desc') { - sortedConfigs = sortedConfigs.reverse(); + const preslicedSortedConfigs = [ + ...fieldStatsParams.metricConfigs, + ...fieldStatsParams.nonMetricConfigs, + ].sort(itemsSorter); + + const filteredItems = filterFields( + preslicedSortedConfigs, + dataVisualizerListState.visibleFieldNames, + dataVisualizerListState.visibleFieldTypes + ); + + const { pageIndex, pageSize } = dataVisualizerListState; + + const pageOfConfigs = filteredItems.filteredFields + ?.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize) + .filter((d) => d.existsInDocs === true); + + if (!pageOfConfigs || pageOfConfigs.length === 0) { + setFetchState({ + loaded: 100, + isRunning: false, + }); + + return; } const filterCriteria = buildBaseFilterCriteria( @@ -149,7 +175,7 @@ export function useFieldStatsSearchStrategy( }; const batches = createBatchedRequests( - sortedConfigs.map((config, idx) => ({ + pageOfConfigs.map((config, idx) => ({ fieldName: config.fieldName, type: config.type, cardinality: config.cardinality, @@ -161,11 +187,10 @@ export function useFieldStatsSearchStrategy( const statsMap$ = new Subject(); const fieldsToRetry$ = new Subject(); - const fieldStatsSub = combineLatest( - batches - .map((batch) => getFieldsStats(data.search, params, batch, searchOptions)) - .filter((obs) => obs !== undefined) as Array> - ); + const fieldStatsToFetch = batches + .map((batch) => getFieldsStats(data.search, params, batch, searchOptions)) + .filter((obs) => obs !== undefined) as Array>; + const onError = (error: any) => { toasts.addError(error, { title: i18n.translate('xpack.dataVisualizer.index.errorFetchingFieldStatisticsMessage', { @@ -184,17 +209,24 @@ export function useFieldStatsSearchStrategy( }); }; + const statsMapTmp = new Map(); + // First, attempt to fetch field stats in batches of 10 - searchSubscription$.current = fieldStatsSub.subscribe({ - next: (resp) => { - if (resp) { - const statsMap = new Map(); - const failedFields: Field[] = []; - resp.forEach((batchResponse) => { + searchSubscription$.current = from(fieldStatsToFetch) + .pipe(mergeMap((observable) => observable, MAX_CONCURRENT_REQUESTS)) + .subscribe({ + next: (batchResponse) => { + setFetchState({ + ...getInitialProgress(), + error: undefined, + }); + + if (batchResponse) { + const failedFields: Field[] = []; if (Array.isArray(batchResponse)) { batchResponse.forEach((f) => { if (f.fieldName !== undefined) { - statsMap.set(f.fieldName, f); + statsMapTmp.set(f.fieldName, f); } }); } else { @@ -202,23 +234,22 @@ export function useFieldStatsSearchStrategy( // retry each field in the failed batch individually failedFields.push(...(batchResponse.fields ?? [])); } - }); - setFetchState({ - loaded: (statsMap.size / sortedConfigs.length) * 100, - isRunning: true, - }); + setFieldStats(statsMapTmp); + setFetchState({ + loaded: (statsMapTmp.size / pageOfConfigs.length) * 100, + isRunning: true, + }); - setFieldStats(statsMap); - if (failedFields.length > 0) { - statsMap$.next(statsMap); - fieldsToRetry$.next(failedFields); + if (failedFields.length > 0) { + statsMap$.next(statsMapTmp); + fieldsToRetry$.next(failedFields); + } } - } - }, - error: onError, - complete: onComplete, - }); + }, + error: onError, + complete: onComplete, + }); // If any of batches failed, retry each of the failed field at least one time individually retries$.current = combineLatest([ @@ -247,7 +278,7 @@ export function useFieldStatsSearchStrategy( }); setFieldStats(statsMap); setFetchState({ - loaded: (statsMap.size / sortedConfigs.length) * 100, + loaded: (statsMap.size / pageOfConfigs.length) * 100, isRunning: true, }); } @@ -256,7 +287,15 @@ export function useFieldStatsSearchStrategy( complete: onComplete, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data.search, toasts, fieldStatsParams, initialDataVisualizerListState]); + }, [ + data.search, + toasts, + fieldStatsParams, + dataVisualizerListState.pageSize, + dataVisualizerListState.pageIndex, + dataVisualizerListState.sortDirection, + dataVisualizerListState.sortField, + ]); const cancelFetch = useCallback(() => { searchSubscription$.current?.unsubscribe(); diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_overall_stats.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_overall_stats.ts index 8995a6adc4d46a..0ad8ec70371537 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_overall_stats.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_overall_stats.ts @@ -6,8 +6,8 @@ */ import { useCallback, useEffect, useState, useRef, useMemo, useReducer } from 'react'; -import { forkJoin, of, Subscription } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { from, of, Subscription, Observable } from 'rxjs'; +import { mergeMap, last, map, toArray } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import type { ToastsStart } from 'kibana/public'; import { chunk } from 'lodash'; @@ -16,6 +16,7 @@ import { AggregatableFieldOverallStats, checkAggregatableFieldsExistRequest, checkNonAggregatableFieldExistsRequest, + isAggregatableFieldOverallStats, processAggregatableFieldsExistResponse, processNonAggregatableFieldsExistResponse, } from '../search_strategy/requests/overall_stats'; @@ -36,6 +37,31 @@ import { processDocumentCountStats, } from '../search_strategy/requests/get_document_stats'; import { getInitialProgress, getReducer } from '../progress_utils'; +import { MAX_CONCURRENT_REQUESTS } from '../constants/index_data_visualizer_viewer'; + +/** + * Helper function to run forkJoin + * with restrictions on how many input observables can be subscribed to concurrently + */ +export function rateLimitingForkJoin( + observables: Array>, + maxConcurrentRequests = MAX_CONCURRENT_REQUESTS +): Observable { + return from(observables).pipe( + mergeMap( + (observable, index) => + observable.pipe( + last(), + map((value) => ({ index, value })) + ), + maxConcurrentRequests + ), + toArray(), + map((indexedObservables) => + indexedObservables.sort((l, r) => l.index - r.index).map((obs) => obs.value) + ) + ); +} function displayError(toastNotifications: ToastsStart, index: string, err: any) { if (err.statusCode === 500) { @@ -116,72 +142,63 @@ export function useOverallStats 0 - ? forkJoin( - nonAggregatableFields.map((fieldName: string) => - data.search - .search( - { - params: checkNonAggregatableFieldExistsRequest( - index, - searchQuery, - fieldName, - timeFieldName, - earliest, - latest, - runtimeFieldMap - ), - }, - searchOptions - ) - .pipe( - switchMap((resp) => { - return of({ - ...resp, - rawResponse: { ...resp.rawResponse, fieldName }, - } as IKibanaSearchResponse); - }) - ) - ) - ) - : of(undefined); + + const nonAggregatableFieldsObs = nonAggregatableFields.map((fieldName: string) => + data.search + .search( + { + params: checkNonAggregatableFieldExistsRequest( + index, + searchQuery, + fieldName, + timeFieldName, + earliest, + latest, + runtimeFieldMap + ), + }, + searchOptions + ) + .pipe( + map((resp) => { + return { + ...resp, + rawResponse: { ...resp.rawResponse, fieldName }, + } as IKibanaSearchResponse; + }) + ) + ); // Have to divide into smaller requests to avoid 413 payload too large const aggregatableFieldsChunks = chunk(aggregatableFields, 30); - const aggregatableOverallStats$ = forkJoin( - aggregatableFields.length > 0 - ? aggregatableFieldsChunks.map((aggregatableFieldsChunk) => - data.search - .search( - { - params: checkAggregatableFieldsExistRequest( - index, - searchQuery, - aggregatableFieldsChunk, - samplerShardSize, - timeFieldName, - earliest, - latest, - undefined, - runtimeFieldMap - ), - }, - searchOptions - ) - .pipe( - switchMap((resp) => { - return of({ - ...resp, - aggregatableFields: aggregatableFieldsChunk, - } as AggregatableFieldOverallStats); - }) - ) - ) - : of(undefined) + const aggregatableOverallStatsObs = aggregatableFieldsChunks.map((aggregatableFieldsChunk) => + data.search + .search( + { + params: checkAggregatableFieldsExistRequest( + index, + searchQuery, + aggregatableFieldsChunk, + samplerShardSize, + timeFieldName, + earliest, + latest, + undefined, + runtimeFieldMap + ), + }, + searchOptions + ) + .pipe( + map((resp) => { + return { + ...resp, + aggregatableFields: aggregatableFieldsChunk, + } as AggregatableFieldOverallStats; + }) + ) ); - const documentCountStats$ = !fieldsToFetch && timeFieldName !== undefined && intervalMs !== undefined && intervalMs > 0 ? data.search.search( @@ -191,28 +208,42 @@ export function useOverallStats { + + const sub = rateLimitingForkJoin< + AggregatableFieldOverallStats | IKibanaSearchResponse | undefined + >( + [documentCountStats$, ...aggregatableOverallStatsObs, ...nonAggregatableFieldsObs], + MAX_CONCURRENT_REQUESTS + ); + + searchSubscription$.current = sub.subscribe({ + next: (value) => { + { + const aggregatableOverallStatsResp: AggregatableFieldOverallStats[] = []; + const nonAggregatableOverallStatsResp: IKibanaSearchResponse[] = []; + const documentCountStatsResp = value[0]; + + value.forEach((resp, idx) => { + if (!resp) return; + if (isAggregatableFieldOverallStats(resp)) { + aggregatableOverallStatsResp.push(resp); + } else { + nonAggregatableOverallStatsResp.push(resp); + } + }); + const aggregatableOverallStats = processAggregatableFieldsExistResponse( aggregatableOverallStatsResp, aggregatableFields, samplerShardSize ); + const nonAggregatableOverallStats = processNonAggregatableFieldsExistResponse( nonAggregatableOverallStatsResp, nonAggregatableFields ); - return of({ + setOverallStats({ documentCountStats: processDocumentCountStats( documentCountStatsResp?.rawResponse, searchStrategyParams @@ -221,14 +252,6 @@ export function useOverallStats { - if (overallStats) { - setOverallStats(overallStats); - } }, error: (error) => { displayError(toasts, searchStrategyParams.index, extractErrorProperties(error)); diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/search_strategy/requests/overall_stats.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/search_strategy/requests/overall_stats.ts index 1e7c99ce254283..a96244ece334af 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/search_strategy/requests/overall_stats.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/search_strategy/requests/overall_stats.ts @@ -90,6 +90,13 @@ export const checkAggregatableFieldsExistRequest = ( export interface AggregatableFieldOverallStats extends IKibanaSearchResponse { aggregatableFields: string[]; } + +export function isAggregatableFieldOverallStats( + arg: unknown +): arg is AggregatableFieldOverallStats { + return isPopulatedObject(arg, ['aggregatableFields']); +} + export const processAggregatableFieldsExistResponse = ( responses: AggregatableFieldOverallStats[] | undefined, aggregatableFields: string[],