[Infra] Fix duplicate requests (#189505)

fixes: [#189484](https://github.com/elastic/kibana/issues/189484)

## Summary

Fix duplicate request during hosts view mounting cycle.


https://github.com/user-attachments/assets/dce73b2a-77e8-461a-bd9e-661fc40afe7a



https://github.com/user-attachments/assets/0e8c41b4-1a36-470f-855d-c189221ad1f6


The problem happened because the `buildEsQuery` returns a complex
object, making the `useFetcher` to treat every payload, as a new one,
triggering duplicate requests.

### Extra

I have refactored the `use_unified_search` and
`use_unified_search_url_state`. It was misusing `useReducer` and there
was some magic happening with `rxJS`. I tried to make the code easier to
understand

### How to test

- Start a local Kibana and es instances
- run `node scripts/synthtrace infra_hosts_with_apm_hosts --live `
- Navigate to Infrastructure > Hosts
- Open the dev tools and check if there is more than one call to
`api/metrics/infra` endpoint

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Carlos Crespo 2024-07-31 12:53:12 +02:00 committed by GitHub
parent 4a63054366
commit b009ea0011
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 117 additions and 84 deletions

View file

@ -16,7 +16,6 @@ import { AlertsCount } from '../../../hooks/use_alerts_count';
import { useKibanaContextForPlugin } from '../../../hooks/use_kibana';
import { createAlertsEsQuery } from '../../../utils/filters/create_alerts_es_query';
import { ALERT_STATUS_ALL, infraAlertFeatureIds } from './constants';
import { HostsStateUpdater } from '../../../pages/metrics/hosts/hooks/use_unified_search_url_state';
import AlertsStatusFilter from './alerts_status_filter';
import { useAssetDetailsUrlState } from '../../asset_details/hooks/use_asset_details_url_state';
@ -24,7 +23,7 @@ interface AlertsOverviewProps {
assetId: string;
dateRange: TimeRange;
onLoaded: (alertsCount?: AlertsCount) => void;
onRangeSelection?: HostsStateUpdater;
onRangeSelection?: (dateRange: TimeRange) => void;
assetType?: InventoryItemType;
}
@ -86,7 +85,7 @@ export const AlertsOverview = ({
const from = new Date(start).toISOString();
const to = new Date(end).toISOString();
onRangeSelection({ dateRange: { from, to } });
onRangeSelection({ from, to });
}
},
[onRangeSelection]

View file

@ -6,7 +6,7 @@
*/
import React, { useCallback } from 'react';
import type { Query, TimeRange, Filter } from '@kbn/es-query';
import type { TimeRange } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { useEuiTheme, EuiHorizontalRule, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { css } from '@emotion/react';
@ -22,28 +22,21 @@ export const UnifiedSearchBar = () => {
services: { unifiedSearch, application },
} = useKibanaContextForPlugin();
const { metricsView } = useMetricsDataViewContext();
const { searchCriteria, onSubmit } = useUnifiedSearchContext();
const { searchCriteria, onLimitChange, onPanelFiltersChange, onSubmit } =
useUnifiedSearchContext();
const { SearchBar } = unifiedSearch.ui;
const onLimitChange = (limit: number) => {
onSubmit({ limit });
};
const onPanelFiltersChange = useCallback(
(panelFilters: Filter[]) => {
onSubmit({ panelFilters });
const handleRefresh = useCallback(
(payload: { dateRange: TimeRange }, isUpdate?: boolean) => {
// This makes sure `onSubmit` is only called when the submit button is clicked
if (isUpdate === false) {
onSubmit(payload);
}
},
[onSubmit]
);
const handleRefresh = (payload: { query?: Query; dateRange: TimeRange }, isUpdate?: boolean) => {
// This makes sure `onQueryChange` is only called when the submit button is clicked
if (isUpdate === false) {
onSubmit(payload);
}
};
return (
<StickyContainer>
<EuiFlexGroup direction="column" gutterSize="s">

View file

@ -10,11 +10,12 @@ import { AlertConsumers, ALERT_RULE_PRODUCER } from '@kbn/rule-data-utils';
import { BrushEndListener, type XYBrushEvent } from '@elastic/charts';
import { useSummaryTimeRange } from '@kbn/observability-plugin/public';
import { useBoolean } from '@kbn/react-hooks';
import type { TimeRange } from '@kbn/es-query';
import { useKibanaContextForPlugin } from '../../../../../../hooks/use_kibana';
import { HeightRetainer } from '../../../../../../components/height_retainer';
import { useUnifiedSearchContext } from '../../../hooks/use_unified_search';
import { useAlertsQuery } from '../../../hooks/use_alerts_query';
import { HostsState, HostsStateUpdater } from '../../../hooks/use_unified_search_url_state';
import type { HostsState } from '../../../hooks/use_unified_search_url_state';
import { AlertsEsQuery } from '../../../../../../utils/filters/create_alerts_es_query';
import {
ALERTS_PER_PAGE,
@ -35,7 +36,7 @@ export const AlertsTabContent = () => {
const { alertStatus, setAlertStatus, alertsEsQueryByStatus } = useAlertsQuery();
const [isAlertFlyoutVisible, { toggle: toggleAlertFlyout }] = useBoolean(false);
const { onSubmit, searchCriteria } = useUnifiedSearchContext();
const { onDateRangeChange, searchCriteria } = useUnifiedSearchContext();
const { triggersActionsUi } = services;
@ -71,7 +72,7 @@ export const AlertsTabContent = () => {
<MemoAlertSummaryWidget
alertsQuery={alertsEsQueryByStatus}
dateRange={searchCriteria.dateRange}
onRangeSelection={onSubmit}
onRangeSelection={onDateRangeChange}
/>
</EuiFlexItem>
{alertsEsQueryByStatus && (
@ -102,7 +103,7 @@ export const AlertsTabContent = () => {
interface MemoAlertSummaryWidgetProps {
alertsQuery: AlertsEsQuery;
dateRange: HostsState['dateRange'];
onRangeSelection: HostsStateUpdater;
onRangeSelection: (dateRange: TimeRange) => void;
}
const MemoAlertSummaryWidget = React.memo(
@ -122,7 +123,7 @@ const MemoAlertSummaryWidget = React.memo(
const from = new Date(start).toISOString();
const to = new Date(end).toISOString();
onRangeSelection({ dateRange: { from, to } });
onRangeSelection({ from, to });
}
};

View file

@ -43,13 +43,15 @@ export const useHostsView = () => {
} = useKibanaContextForPlugin();
const { buildQuery, parsedDateRange, searchCriteria } = useUnifiedSearchContext();
const baseRequest = useMemo(
const payload = useMemo(
() =>
createInfraMetricsRequest({
dateRange: parsedDateRange,
esQuery: buildQuery(),
limit: searchCriteria.limit,
}),
JSON.stringify(
createInfraMetricsRequest({
dateRange: parsedDateRange,
esQuery: buildQuery(),
limit: searchCriteria.limit,
})
),
[buildQuery, parsedDateRange, searchCriteria.limit]
);
@ -60,7 +62,7 @@ export const useHostsView = () => {
BASE_INFRA_METRICS_PATH,
{
method: 'POST',
body: JSON.stringify(baseRequest),
body: payload,
}
);
const duration = performance.now() - start;
@ -72,7 +74,7 @@ export const useHostsView = () => {
);
return metricsResponse;
},
[baseRequest, searchCriteria.limit, telemetry]
[payload, searchCriteria.limit, telemetry]
);
return {

View file

@ -6,9 +6,8 @@
*/
import createContainer from 'constate';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { buildEsQuery, fromKueryExpression, type Query } from '@kbn/es-query';
import { map, skip, startWith } from 'rxjs';
import { combineLatest } from 'rxjs';
import { buildEsQuery, Filter, fromKueryExpression, TimeRange, type Query } from '@kbn/es-query';
import { Subscription, map, tap } from 'rxjs';
import deepEqual from 'fast-deep-equal';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { useSearchSessionContext } from '../../../../hooks/use_search_session';
@ -18,10 +17,10 @@ import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana';
import { telemetryTimeRangeFormatter } from '../../../../../common/formatters/telemetry_time_range';
import { useMetricsDataViewContext } from '../../../../containers/metrics_source';
import {
HostsSearchPayload,
useHostsUrlState,
type HostsState,
type StringDateRangeTimestamp,
StringDateRange,
} from './use_unified_search_url_state';
import { retrieveFieldsFromFilter } from '../../../../utils/filters/build';
@ -60,11 +59,7 @@ export const useUnifiedSearch = () => {
const {
data: {
query: {
filterManager: filterManagerService,
queryString: queryStringService,
timefilter: timeFilterService,
},
query: { filterManager: filterManagerService, queryString: queryStringService },
},
telemetry,
} = services;
@ -76,30 +71,53 @@ export const useUnifiedSearch = () => {
[kibanaQuerySettings]
);
const onSubmit = useCallback(
(params?: HostsSearchPayload) => {
const onFiltersChange = useCallback(
(filters: Filter[]) => {
setSearch({ type: 'SET_FILTERS', filters });
},
[setSearch]
);
const onPanelFiltersChange = useCallback(
(panelFilters: Filter[]) => {
setSearch({ type: 'SET_PANEL_FILTERS', panelFilters });
},
[setSearch]
);
const onLimitChange = useCallback(
(limit: number) => {
setSearch({ type: 'SET_LIMIT', limit });
},
[setSearch]
);
const onDateRangeChange = useCallback(
(dateRange: StringDateRange) => {
setSearch({ type: 'SET_DATE_RANGE', dateRange });
},
[setSearch]
);
const onQueryChange = useCallback(
(query: Query) => {
try {
setError(null);
/*
/ Validates the Search Bar input values before persisting them in the state.
/ Since the search can be triggered by components that are unaware of the Unified Search state (e.g Controls and Host Limit),
/ this will always validates the query bar value, regardless of whether it's been sent in the current event or not.
*/
validateQuery(params?.query ?? (queryStringService.getQuery() as Query));
setSearch(params ?? {});
updateSearchSessionId();
validateQuery(query);
setSearch({ type: 'SET_QUERY', query });
} catch (err) {
/*
/ Persists in the state the params so they can be used in case the query bar is fixed by the user.
/ This is needed because the Unified Search observables are unnaware of the other componets in the search bar.
/ Invalid query isn't persisted because it breaks the Control component
*/
const { query, ...validParams } = params ?? {};
setSearch(validParams ?? {});
setError(err);
}
},
[queryStringService, setSearch, updateSearchSessionId, validateQuery]
[validateQuery, setSearch]
);
const onSubmit = useCallback(
({ dateRange }: { dateRange: TimeRange }) => {
onDateRangeChange(dateRange);
updateSearchSessionId();
},
[onDateRangeChange, updateSearchSessionId]
);
const parsedDateRange = useMemo(() => {
@ -153,27 +171,31 @@ export const useUnifiedSearch = () => {
});
useEffect(() => {
const filters$ = filterManagerService.getUpdates$().pipe(
startWith(undefined),
map(() => filterManagerService.getFilters())
const subscription = new Subscription();
subscription.add(
filterManagerService
.getUpdates$()
.pipe(
map(() => filterManagerService.getFilters()),
tap((filters) => onFiltersChange(filters))
)
.subscribe()
);
const query$ = queryStringService.getUpdates$().pipe(
startWith(undefined),
map(() => queryStringService.getQuery() as Query)
subscription.add(
queryStringService
.getUpdates$()
.pipe(
map(() => queryStringService.getQuery() as Query),
tap((query) => onQueryChange(query))
)
.subscribe()
);
const subscription = combineLatest({
filters: filters$,
query: query$,
})
.pipe(skip(1))
.subscribe(onSubmit);
return () => {
subscription.unsubscribe();
};
}, [filterManagerService, onSubmit, queryStringService, timeFilterService.timefilter]);
}, [filterManagerService, queryStringService, onQueryChange, onFiltersChange]);
// Track telemetry event on query/filter/date changes
useEffect(() => {
@ -190,6 +212,9 @@ export const useUnifiedSearch = () => {
parsedDateRange,
getDateRangeAsTimestamp,
searchCriteria,
onDateRangeChange,
onLimitChange,
onPanelFiltersChange,
};
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { useReducer } from 'react';
import { Dispatch, useReducer } from 'react';
import deepEqual from 'fast-deep-equal';
import * as rt from 'io-ts';
import { pipe } from 'fp-ts/lib/pipeable';
@ -37,16 +37,31 @@ const INITIAL_HOSTS_STATE: HostsState = {
limit: DEFAULT_HOST_LIMIT,
};
const reducer = (prevState: HostsState, params: HostsSearchPayload) => {
const payload = Object.fromEntries(Object.entries(params).filter(([_, v]) => !!v));
export type HostsStateAction =
| { type: 'SET_DATE_RANGE'; dateRange: StringDateRange }
| { type: 'SET_LIMIT'; limit: number }
| { type: 'SET_FILTERS'; filters: HostsState['filters'] }
| { type: 'SET_QUERY'; query: HostsState['query'] }
| { type: 'SET_PANEL_FILTERS'; panelFilters: HostsState['panelFilters'] };
return {
...prevState,
...payload,
};
const reducer = (state: HostsState, action: HostsStateAction): HostsState => {
switch (action.type) {
case 'SET_DATE_RANGE':
return { ...state, dateRange: action.dateRange };
case 'SET_LIMIT':
return { ...state, limit: action.limit };
case 'SET_FILTERS':
return { ...state, filters: action.filters };
case 'SET_QUERY':
return { ...state, query: action.query };
case 'SET_PANEL_FILTERS':
return { ...state, panelFilters: action.panelFilters };
default:
return state;
}
};
export const useHostsUrlState = (): [HostsState, HostsStateUpdater] => {
export const useHostsUrlState = (): [HostsState, Dispatch<HostsStateAction>] => {
const [getTime] = useKibanaTimefilterTime(INITIAL_DATE_RANGE);
const [localStorageHostLimit, setLocalStorageHostLimit] = useLocalStorage<number>(
LOCAL_STORAGE_HOST_LIMIT_KEY,
@ -74,7 +89,7 @@ export const useHostsUrlState = (): [HostsState, HostsStateUpdater] => {
}
useSyncKibanaTimeFilterTime(INITIAL_DATE_RANGE, urlState.dateRange, (dateRange) =>
setSearch({ dateRange })
setSearch({ type: 'SET_DATE_RANGE', dateRange })
);
return [search, setSearch];
@ -131,8 +146,6 @@ export type HostsState = rt.TypeOf<typeof HostsStateRT>;
export type HostsSearchPayload = Partial<HostsState>;
export type HostsStateUpdater = (params: HostsSearchPayload) => void;
export type StringDateRange = rt.TypeOf<typeof StringDateRangeRT>;
export interface StringDateRangeTimestamp {
from: number;