diff --git a/x-pack/plugins/apm/common/search_strategies/correlations/types.ts b/x-pack/plugins/apm/common/search_strategies/correlations/types.ts index 703106628f561..886c5fd6161d8 100644 --- a/x-pack/plugins/apm/common/search_strategies/correlations/types.ts +++ b/x-pack/plugins/apm/common/search_strategies/correlations/types.ts @@ -52,3 +52,12 @@ export interface AsyncSearchProviderProgress { loadedFieldValuePairs: number; loadedHistograms: number; } + +export interface SearchServiceRawResponse { + ccsWarning: boolean; + log: string[]; + overallHistogram?: HistogramItem[]; + percentileThresholdValue?: number; + took: number; + values: SearchServiceValue[]; +} diff --git a/x-pack/plugins/apm/public/components/app/correlations/empty_state_prompt.tsx b/x-pack/plugins/apm/public/components/app/correlations/empty_state_prompt.tsx index 57e57a526baff..9b161fc1b9fa9 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/empty_state_prompt.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/empty_state_prompt.tsx @@ -33,8 +33,7 @@ export function CorrelationsEmptyStatePrompt() { id="xpack.apm.correlations.noCorrelationsTextLine1" defaultMessage="Correlations will only be identified if they have significant impact." /> -

-

+
(enableInspectEsQueries); - const searchServicePrams: SearchServiceParams = { - environment, - kuery, - serviceName, - transactionName, - transactionType, - start, - end, - }; - const result = useFailedTransactionsCorrelationsFetcher(); const { @@ -93,26 +82,30 @@ export function FailedTransactionsCorrelations({ } = result; const startFetchHandler = useCallback(() => { - startFetch(searchServicePrams); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [environment, serviceName, kuery, start, end]); + startFetch({ + environment, + kuery, + serviceName, + transactionName, + transactionType, + start, + end, + }); + }, [ + startFetch, + environment, + serviceName, + transactionName, + transactionType, + kuery, + start, + end, + ]); - // start fetching on load - // we want this effect to execute exactly once after the component mounts useEffect(() => { - if (isRunning) { - cancelFetch(); - } - startFetchHandler(); - - return () => { - // cancel any running async partial request when unmounting the component - // we want this effect to execute exactly once after the component mounts - cancelFetch(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [startFetchHandler]); + return cancelFetch; + }, [cancelFetch, startFetchHandler]); const [ selectedSignificantTerm, diff --git a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.test.tsx b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.test.tsx new file mode 100644 index 0000000000000..b0da5b6d60d74 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.test.tsx @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import React, { ReactNode } from 'react'; +import { of } from 'rxjs'; + +import { __IntlProvider as IntlProvider } from '@kbn/i18n/react'; + +import { CoreStart } from 'kibana/public'; +import { merge } from 'lodash'; +import { dataPluginMock } from 'src/plugins/data/public/mocks'; +import type { IKibanaSearchResponse } from 'src/plugins/data/public'; +import { EuiThemeProvider } from 'src/plugins/kibana_react/common'; +import { createKibanaReactContext } from 'src/plugins/kibana_react/public'; +import type { SearchServiceRawResponse } from '../../../../common/search_strategies/correlations/types'; +import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; +import { ApmPluginContextValue } from '../../../context/apm_plugin/apm_plugin_context'; +import { + mockApmPluginContextValue, + MockApmPluginContextWrapper, +} from '../../../context/apm_plugin/mock_apm_plugin_context'; +import { fromQuery } from '../../shared/Links/url_helpers'; + +import { LatencyCorrelations } from './latency_correlations'; + +function Wrapper({ + children, + dataSearchResponse, +}: { + children?: ReactNode; + dataSearchResponse: IKibanaSearchResponse; +}) { + const mockDataSearch = jest.fn(() => of(dataSearchResponse)); + + const dataPluginMockStart = dataPluginMock.createStartContract(); + const KibanaReactContext = createKibanaReactContext({ + data: { + ...dataPluginMockStart, + search: { + ...dataPluginMockStart.search, + search: mockDataSearch, + }, + }, + usageCollection: { reportUiCounter: () => {} }, + } as Partial); + + const httpGet = jest.fn(); + + const history = createMemoryHistory(); + jest.spyOn(history, 'push'); + jest.spyOn(history, 'replace'); + + history.replace({ + pathname: '/services/the-service-name/transactions/view', + search: fromQuery({ transactionName: 'the-transaction-name' }), + }); + + const mockPluginContext = (merge({}, mockApmPluginContextValue, { + core: { http: { get: httpGet } }, + }) as unknown) as ApmPluginContextValue; + + return ( + + + + + + {children} + + + + + + ); +} + +describe('correlations', () => { + describe('LatencyCorrelations', () => { + it('shows loading indicator when the service is running and returned no results yet', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('apmCorrelationsChart')).toBeInTheDocument(); + expect(screen.getByTestId('loading')).toBeInTheDocument(); + }); + }); + + it("doesn't show loading indicator when the service isn't running", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('apmCorrelationsChart')).toBeInTheDocument(); + expect(screen.queryByTestId('loading')).toBeNull(); // it doesn't exist + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx index 74702e621a0ba..ad8a56a3ac6f9 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx @@ -61,7 +61,7 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { const { query: { kuery, environment, rangeFrom, rangeTo }, - } = useApmParams('/services/:serviceName'); + } = useApmParams('/services/:serviceName/transactions/view'); const { urlParams } = useUrlParams(); @@ -95,25 +95,21 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { end, percentileThreshold: DEFAULT_PERCENTILE_THRESHOLD, }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [environment, serviceName, kuery, start, end]); + }, [ + startFetch, + environment, + serviceName, + transactionName, + transactionType, + kuery, + start, + end, + ]); - // start fetching on load - // we want this effect to execute exactly once after the component mounts useEffect(() => { - if (isRunning) { - cancelFetch(); - } - startFetchHandler(); - - return () => { - // cancel any running async partial request when unmounting the component - // we want this effect to execute exactly once after the component mounts - cancelFetch(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [startFetchHandler]); + return cancelFetch; + }, [cancelFetch, startFetchHandler]); useEffect(() => { if (isErrorMessage(error)) { diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.ts b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.ts deleted file mode 100644 index f541c16e655ab..0000000000000 --- a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.ts +++ /dev/null @@ -1,22 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getFormattedSelection } from './index'; - -describe('transaction_details/distribution', () => { - describe('getFormattedSelection', () => { - it('displays only one unit if from and to share the same unit', () => { - expect(getFormattedSelection([10000, 100000])).toEqual('10 - 100 ms'); - }); - - it('displays two units when from and to have different units', () => { - expect(getFormattedSelection([100000, 1000000000])).toEqual( - '100 ms - 17 min' - ); - }); - }); -}); diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.tsx new file mode 100644 index 0000000000000..5a9977b373c33 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.tsx @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import React, { ReactNode } from 'react'; +import { of } from 'rxjs'; + +import { CoreStart } from 'kibana/public'; +import { merge } from 'lodash'; +import { dataPluginMock } from 'src/plugins/data/public/mocks'; +import type { IKibanaSearchResponse } from 'src/plugins/data/public'; +import { EuiThemeProvider } from 'src/plugins/kibana_react/common'; +import { createKibanaReactContext } from 'src/plugins/kibana_react/public'; +import type { SearchServiceRawResponse } from '../../../../../common/search_strategies/correlations/types'; +import { MockUrlParamsContextProvider } from '../../../../context/url_params_context/mock_url_params_context_provider'; +import { ApmPluginContextValue } from '../../../../context/apm_plugin/apm_plugin_context'; +import { + mockApmPluginContextValue, + MockApmPluginContextWrapper, +} from '../../../../context/apm_plugin/mock_apm_plugin_context'; +import { fromQuery } from '../../../shared/Links/url_helpers'; + +import { getFormattedSelection, TransactionDistribution } from './index'; + +function Wrapper({ + children, + dataSearchResponse, +}: { + children?: ReactNode; + dataSearchResponse: IKibanaSearchResponse; +}) { + const mockDataSearch = jest.fn(() => of(dataSearchResponse)); + + const dataPluginMockStart = dataPluginMock.createStartContract(); + const KibanaReactContext = createKibanaReactContext({ + data: { + ...dataPluginMockStart, + search: { + ...dataPluginMockStart.search, + search: mockDataSearch, + }, + }, + usageCollection: { reportUiCounter: () => {} }, + } as Partial); + + const httpGet = jest.fn(); + + const history = createMemoryHistory(); + jest.spyOn(history, 'push'); + jest.spyOn(history, 'replace'); + + history.replace({ + pathname: '/services/the-service-name/transactions/view', + search: fromQuery({ transactionName: 'the-transaction-name' }), + }); + + const mockPluginContext = (merge({}, mockApmPluginContextValue, { + core: { http: { get: httpGet } }, + }) as unknown) as ApmPluginContextValue; + + return ( + + + + + {children} + + + + + ); +} + +describe('transaction_details/distribution', () => { + describe('getFormattedSelection', () => { + it('displays only one unit if from and to share the same unit', () => { + expect(getFormattedSelection([10000, 100000])).toEqual('10 - 100 ms'); + }); + + it('displays two units when from and to have different units', () => { + expect(getFormattedSelection([100000, 1000000000])).toEqual( + '100 ms - 17 min' + ); + }); + }); + + describe('TransactionDistribution', () => { + it('shows loading indicator when the service is running and returned no results yet', async () => { + const onHasData = jest.fn(); + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('apmCorrelationsChart')).toBeInTheDocument(); + expect(screen.getByTestId('loading')).toBeInTheDocument(); + expect(onHasData).toHaveBeenLastCalledWith(false); + }); + }); + + it("doesn't show loading indicator when the service isn't running", async () => { + const onHasData = jest.fn(); + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('apmCorrelationsChart')).toBeInTheDocument(); + expect(screen.queryByTestId('loading')).toBeNull(); // it doesn't exist + expect(onHasData).toHaveBeenLastCalledWith(false); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx index 86ebc04944cd5..2da61bc0fc555 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { BrushEndListener, XYBrushArea } from '@elastic/charts'; import { EuiBadge, @@ -21,7 +21,10 @@ import { getDurationFormatter } from '../../../../../common/utils/formatters'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; import { useTransactionDistributionFetcher } from '../../../../hooks/use_transaction_distribution_fetcher'; -import { TransactionDistributionChart } from '../../../shared/charts/transaction_distribution_chart'; +import { + OnHasData, + TransactionDistributionChart, +} from '../../../shared/charts/transaction_distribution_chart'; import { useUiTracker } from '../../../../../../observability/public'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; import { useApmParams } from '../../../../hooks/use_apm_params'; @@ -47,10 +50,11 @@ export function getFormattedSelection(selection: Selection): string { }`; } -interface Props { +interface TransactionDistributionProps { markerCurrentTransaction?: number; onChartSelection: BrushEndListener; onClearSelection: () => void; + onHasData: OnHasData; selection?: Selection; } @@ -58,8 +62,9 @@ export function TransactionDistribution({ markerCurrentTransaction, onChartSelection, onClearSelection, + onHasData, selection, -}: Props) { +}: TransactionDistributionProps) { const { core: { notifications }, } = useApmPluginContext(); @@ -68,7 +73,7 @@ export function TransactionDistribution({ const { query: { kuery, environment, rangeFrom, rangeTo }, - } = useApmParams('/services/:serviceName'); + } = useApmParams('/services/:serviceName/transactions/view'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); @@ -76,6 +81,16 @@ export function TransactionDistribution({ const { transactionName } = urlParams; + const [showSelection, setShowSelection] = useState(false); + + const onTransactionDistributionHasData: OnHasData = useCallback( + (hasData) => { + setShowSelection(hasData); + onHasData(hasData); + }, + [onHasData] + ); + const emptySelectionText = i18n.translate( 'xpack.apm.transactionDetails.emptySelectionText', { @@ -93,17 +108,12 @@ export function TransactionDistribution({ const { error, percentileThresholdValue, - isRunning, startFetch, cancelFetch, transactionDistribution, } = useTransactionDistributionFetcher(); - useEffect(() => { - if (isRunning) { - cancelFetch(); - } - + const startFetchHandler = useCallback(() => { startFetch({ environment, kuery, @@ -114,14 +124,21 @@ export function TransactionDistribution({ end, percentileThreshold: DEFAULT_PERCENTILE_THRESHOLD, }); + }, [ + startFetch, + environment, + serviceName, + transactionName, + transactionType, + kuery, + start, + end, + ]); - return () => { - // cancel any running async partial request when unmounting the component - // we want this effect to execute exactly once after the component mounts - cancelFetch(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [environment, serviceName, kuery, start, end]); + useEffect(() => { + startFetchHandler(); + return cancelFetch; + }, [cancelFetch, startFetchHandler]); useEffect(() => { if (isErrorMessage(error)) { @@ -166,7 +183,7 @@ export function TransactionDistribution({ - {!selection && ( + {showSelection && !selection && ( )} - {selection && ( + {showSelection && selection && ( diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/trace_samples_tab.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/trace_samples_tab.tsx index 0421fcd055d6c..ea02cfea5a941 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/trace_samples_tab.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/trace_samples_tab.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useState } from 'react'; import { EuiSpacer } from '@elastic/eui'; @@ -34,11 +34,17 @@ function TraceSamplesTab({ status: waterfallStatus, } = useWaterfallFetcher(); + const [ + transactionDistributionHasData, + setTransactionDistributionHasData, + ] = useState(false); + return ( <> - + {transactionDistributionHasData && ( + <> + - + + + )} ); } diff --git a/x-pack/plugins/apm/public/components/shared/charts/chart_container.tsx b/x-pack/plugins/apm/public/components/shared/charts/chart_container.tsx index 4098fc5e696db..695e62b3b7d78 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/chart_container.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/chart_container.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; -interface Props { +export interface ChartContainerProps { hasData: boolean; status: FETCH_STATUS; height: number; @@ -24,7 +24,7 @@ export function ChartContainer({ status, hasData, id, -}: Props) { +}: ChartContainerProps) { if (!hasData && status === FETCH_STATUS.LOADING) { return ; } diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_distribution_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_distribution_chart/index.tsx index c511a708058d3..a58a2887b1576 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_distribution_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_distribution_chart/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { AnnotationDomainType, AreaSeries, @@ -35,7 +35,14 @@ import { HistogramItem } from '../../../../../common/search_strategies/correlati import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { useTheme } from '../../../../hooks/use_theme'; -import { ChartContainer } from '../chart_container'; +import { ChartContainer, ChartContainerProps } from '../chart_container'; + +export type TransactionDistributionChartLoadingState = Pick< + ChartContainerProps, + 'hasData' | 'status' +>; + +export type OnHasData = (hasData: boolean) => void; interface TransactionDistributionChartProps { field?: string; @@ -46,6 +53,7 @@ interface TransactionDistributionChartProps { markerPercentile: number; overallHistogram?: HistogramItem[]; onChartSelection?: BrushEndListener; + onHasData?: OnHasData; selection?: [number, number]; } @@ -103,6 +111,7 @@ export function TransactionDistributionChart({ markerPercentile, overallHistogram, onChartSelection, + onHasData, selection, }: TransactionDistributionChartProps) { const chartTheme = useChartTheme(); @@ -154,6 +163,24 @@ export function TransactionDistributionChart({ ] : undefined; + const chartLoadingState: TransactionDistributionChartLoadingState = useMemo( + () => ({ + hasData: + Array.isArray(patchedOverallHistogram) && + patchedOverallHistogram.length > 0, + status: Array.isArray(patchedOverallHistogram) + ? FETCH_STATUS.SUCCESS + : FETCH_STATUS.LOADING, + }), + [patchedOverallHistogram] + ); + + useEffect(() => { + if (onHasData) { + onHasData(chartLoadingState.hasData); + } + }, [chartLoadingState, onHasData]); + return (

0 - } - status={ - Array.isArray(patchedOverallHistogram) - ? FETCH_STATUS.SUCCESS - : FETCH_STATUS.LOADING - } + hasData={chartLoadingState.hasData} + status={chartLoadingState.status} > { })); } - const startFetch = (params: SearchServiceParams) => { - setFetchState((prevState) => ({ - ...prevState, - error: undefined, - isComplete: false, - })); - searchSubscription$.current?.unsubscribe(); - abortCtrl.current.abort(); - abortCtrl.current = new AbortController(); + const startFetch = useCallback( + (params: SearchServiceParams) => { + setFetchState((prevState) => ({ + ...prevState, + error: undefined, + isComplete: false, + })); + searchSubscription$.current?.unsubscribe(); + abortCtrl.current.abort(); + abortCtrl.current = new AbortController(); - const req = { params }; + const req = { params }; - // Submit the search request using the `data.search` service. - searchSubscription$.current = data.search - .search>(req, { - strategy: FAILED_TRANSACTIONS_CORRELATION_SEARCH_STRATEGY, - abortSignal: abortCtrl.current.signal, - }) - .subscribe({ - next: (res: IKibanaSearchResponse) => { - setResponse(res); - if (isCompleteResponse(res)) { - searchSubscription$.current?.unsubscribe(); + // Submit the search request using the `data.search` service. + searchSubscription$.current = data.search + .search>(req, { + strategy: FAILED_TRANSACTIONS_CORRELATION_SEARCH_STRATEGY, + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (res: IKibanaSearchResponse) => { + setResponse(res); + if (isCompleteResponse(res)) { + searchSubscription$.current?.unsubscribe(); + setFetchState((prevState) => ({ + ...prevState, + isRunnning: false, + isComplete: true, + })); + } else if (isErrorResponse(res)) { + searchSubscription$.current?.unsubscribe(); + setFetchState((prevState) => ({ + ...prevState, + error: (res as unknown) as Error, + isRunning: false, + })); + } + }, + error: (error: Error) => { setFetchState((prevState) => ({ ...prevState, - isRunnning: false, - isComplete: true, + error, + isRunning: false, })); - } else if (isErrorResponse(res)) { - searchSubscription$.current?.unsubscribe(); - setFetchState((prevState) => ({ - ...prevState, - error: (res as unknown) as Error, - setIsRunning: false, - })); - } - }, - error: (error: Error) => { - setFetchState((prevState) => ({ - ...prevState, - error, - setIsRunning: false, - })); - }, - }); - }; + }, + }); + }, + [data.search, setFetchState] + ); - const cancelFetch = () => { + const cancelFetch = useCallback(() => { searchSubscription$.current?.unsubscribe(); searchSubscription$.current = undefined; abortCtrl.current.abort(); setFetchState((prevState) => ({ ...prevState, - setIsRunning: false, + isRunning: false, })); - }; + }, [setFetchState]); return { ...fetchState, diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts index 870dc8030d70b..2ff1b83ef1782 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import type { Subscription } from 'rxjs'; import { IKibanaSearchRequest, @@ -14,31 +14,21 @@ import { isErrorResponse, } from '../../../../../src/plugins/data/public'; import type { - HistogramItem, SearchServiceParams, - SearchServiceValue, + SearchServiceRawResponse, } from '../../common/search_strategies/correlations/types'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import { ApmPluginStartDeps } from '../plugin'; -interface RawResponse { - percentileThresholdValue?: number; - took: number; - values: SearchServiceValue[]; - overallHistogram: HistogramItem[]; - log: string[]; - ccsWarning: boolean; -} - interface TransactionDistributionFetcherState { error?: Error; isComplete: boolean; isRunning: boolean; loaded: number; - ccsWarning: RawResponse['ccsWarning']; - log: RawResponse['log']; - transactionDistribution?: RawResponse['overallHistogram']; - percentileThresholdValue?: RawResponse['percentileThresholdValue']; + ccsWarning: SearchServiceRawResponse['ccsWarning']; + log: SearchServiceRawResponse['log']; + transactionDistribution?: SearchServiceRawResponse['overallHistogram']; + percentileThresholdValue?: SearchServiceRawResponse['percentileThresholdValue']; timeTook?: number; total: number; } @@ -63,7 +53,9 @@ export function useTransactionDistributionFetcher() { const abortCtrl = useRef(new AbortController()); const searchSubscription$ = useRef(); - function setResponse(response: IKibanaSearchResponse) { + function setResponse( + response: IKibanaSearchResponse + ) { setFetchState((prevState) => ({ ...prevState, isRunning: response.isRunning || false, @@ -83,71 +75,81 @@ export function useTransactionDistributionFetcher() { response.rawResponse?.percentileThresholdValue, } : {}), + // if loading is done but didn't return any data for the overall histogram, + // set it to an empty array so the consuming chart component knows loading is done. + ...(!response.isRunning && + response.rawResponse?.overallHistogram === undefined + ? { transactionDistribution: [] } + : {}), })); } - const startFetch = ( - params: Omit - ) => { - setFetchState((prevState) => ({ - ...prevState, - error: undefined, - isComplete: false, - })); - searchSubscription$.current?.unsubscribe(); - abortCtrl.current.abort(); - abortCtrl.current = new AbortController(); + const startFetch = useCallback( + (params: Omit) => { + setFetchState((prevState) => ({ + ...prevState, + error: undefined, + isComplete: false, + })); + searchSubscription$.current?.unsubscribe(); + abortCtrl.current.abort(); + abortCtrl.current = new AbortController(); - const searchServiceParams: SearchServiceParams = { - ...params, - analyzeCorrelations: false, - }; - const req = { params: searchServiceParams }; + const searchServiceParams: SearchServiceParams = { + ...params, + analyzeCorrelations: false, + }; + const req = { params: searchServiceParams }; - // Submit the search request using the `data.search` service. - searchSubscription$.current = data.search - .search>(req, { - strategy: 'apmCorrelationsSearchStrategy', - abortSignal: abortCtrl.current.signal, - }) - .subscribe({ - next: (res: IKibanaSearchResponse) => { - setResponse(res); - if (isCompleteResponse(res)) { - searchSubscription$.current?.unsubscribe(); - setFetchState((prevState) => ({ - ...prevState, - isRunnning: false, - isComplete: true, - })); - } else if (isErrorResponse(res)) { - searchSubscription$.current?.unsubscribe(); + // Submit the search request using the `data.search` service. + searchSubscription$.current = data.search + .search< + IKibanaSearchRequest, + IKibanaSearchResponse + >(req, { + strategy: 'apmCorrelationsSearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (res: IKibanaSearchResponse) => { + setResponse(res); + if (isCompleteResponse(res)) { + searchSubscription$.current?.unsubscribe(); + setFetchState((prevState) => ({ + ...prevState, + isRunnning: false, + isComplete: true, + })); + } else if (isErrorResponse(res)) { + searchSubscription$.current?.unsubscribe(); + setFetchState((prevState) => ({ + ...prevState, + error: (res as unknown) as Error, + isRunning: false, + })); + } + }, + error: (error: Error) => { setFetchState((prevState) => ({ ...prevState, - error: (res as unknown) as Error, - setIsRunning: false, + error, + isRunning: false, })); - } - }, - error: (error: Error) => { - setFetchState((prevState) => ({ - ...prevState, - error, - setIsRunning: false, - })); - }, - }); - }; + }, + }); + }, + [data.search, setFetchState] + ); - const cancelFetch = () => { + const cancelFetch = useCallback(() => { searchSubscription$.current?.unsubscribe(); searchSubscription$.current = undefined; abortCtrl.current.abort(); setFetchState((prevState) => ({ ...prevState, - setIsRunning: false, + isRunning: false, })); - }; + }, [setFetchState]); return { ...fetchState, diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_latency_correlations_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_latency_correlations_fetcher.ts index 49f2a279f4931..0b035c6af2354 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_latency_correlations_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_latency_correlations_fetcher.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import type { Subscription } from 'rxjs'; import { IKibanaSearchRequest, @@ -14,32 +14,22 @@ import { isErrorResponse, } from '../../../../../src/plugins/data/public'; import type { - HistogramItem, SearchServiceParams, - SearchServiceValue, + SearchServiceRawResponse, } from '../../common/search_strategies/correlations/types'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import { ApmPluginStartDeps } from '../plugin'; -interface RawResponse { - percentileThresholdValue?: number; - took: number; - values: SearchServiceValue[]; - overallHistogram: HistogramItem[]; - log: string[]; - ccsWarning: boolean; -} - interface TransactionLatencyCorrelationsFetcherState { error?: Error; isComplete: boolean; isRunning: boolean; loaded: number; - ccsWarning: RawResponse['ccsWarning']; - histograms: RawResponse['values']; - log: RawResponse['log']; - overallHistogram?: RawResponse['overallHistogram']; - percentileThresholdValue?: RawResponse['percentileThresholdValue']; + ccsWarning: SearchServiceRawResponse['ccsWarning']; + histograms: SearchServiceRawResponse['values']; + log: SearchServiceRawResponse['log']; + overallHistogram?: SearchServiceRawResponse['overallHistogram']; + percentileThresholdValue?: SearchServiceRawResponse['percentileThresholdValue']; timeTook?: number; total: number; } @@ -65,7 +55,9 @@ export const useTransactionLatencyCorrelationsFetcher = () => { const abortCtrl = useRef(new AbortController()); const searchSubscription$ = useRef(); - function setResponse(response: IKibanaSearchResponse) { + function setResponse( + response: IKibanaSearchResponse + ) { setFetchState((prevState) => ({ ...prevState, isRunning: response.isRunning || false, @@ -85,71 +77,86 @@ export const useTransactionLatencyCorrelationsFetcher = () => { response.rawResponse?.percentileThresholdValue, } : {}), + // if loading is done but didn't return any data for the overall histogram, + // set it to an empty array so the consuming chart component knows loading is done. + ...(!response.isRunning && + response.rawResponse?.overallHistogram === undefined + ? { overallHistogram: [] } + : {}), })); } - const startFetch = ( - params: Omit - ) => { - setFetchState((prevState) => ({ - ...prevState, - error: undefined, - isComplete: false, - })); - searchSubscription$.current?.unsubscribe(); - abortCtrl.current.abort(); - abortCtrl.current = new AbortController(); + const startFetch = useCallback( + (params: Omit) => { + setFetchState((prevState) => ({ + ...prevState, + error: undefined, + isComplete: false, + })); + searchSubscription$.current?.unsubscribe(); + abortCtrl.current.abort(); + abortCtrl.current = new AbortController(); - const searchServiceParams: SearchServiceParams = { - ...params, - analyzeCorrelations: true, - }; - const req = { params: searchServiceParams }; + const searchServiceParams: SearchServiceParams = { + ...params, + analyzeCorrelations: true, + }; + const req = { params: searchServiceParams }; - // Submit the search request using the `data.search` service. - searchSubscription$.current = data.search - .search>(req, { - strategy: 'apmCorrelationsSearchStrategy', - abortSignal: abortCtrl.current.signal, - }) - .subscribe({ - next: (res: IKibanaSearchResponse) => { - setResponse(res); - if (isCompleteResponse(res)) { - searchSubscription$.current?.unsubscribe(); + // Submit the search request using the `data.search` service. + searchSubscription$.current = data.search + .search< + IKibanaSearchRequest, + IKibanaSearchResponse + >(req, { + strategy: 'apmCorrelationsSearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (res: IKibanaSearchResponse) => { + setResponse(res); + if (isCompleteResponse(res)) { + searchSubscription$.current?.unsubscribe(); + setFetchState((prevState) => ({ + ...prevState, + isRunnning: false, + isComplete: true, + })); + } else if (isErrorResponse(res)) { + searchSubscription$.current?.unsubscribe(); + setFetchState((prevState) => ({ + ...prevState, + error: (res as unknown) as Error, + isRunning: false, + })); + } + }, + error: (error: Error) => { setFetchState((prevState) => ({ ...prevState, - isRunnning: false, - isComplete: true, + error, + isRunning: false, })); - } else if (isErrorResponse(res)) { - searchSubscription$.current?.unsubscribe(); - setFetchState((prevState) => ({ - ...prevState, - error: (res as unknown) as Error, - setIsRunning: false, - })); - } - }, - error: (error: Error) => { - setFetchState((prevState) => ({ - ...prevState, - error, - setIsRunning: false, - })); - }, - }); - }; + }, + }); + }, + [data.search, setFetchState] + ); - const cancelFetch = () => { + const cancelFetch = useCallback(() => { searchSubscription$.current?.unsubscribe(); searchSubscription$.current = undefined; abortCtrl.current.abort(); setFetchState((prevState) => ({ ...prevState, - setIsRunning: false, + // If we didn't receive data for the overall histogram yet + // set it to an empty array to indicate loading stopped. + ...(prevState.overallHistogram === undefined + ? { overallHistogram: [] } + : {}), + isRunning: false, })); - }; + }, [setFetchState]); return { ...fetchState, diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/search_strategy.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/search_strategy.ts index 3601f19ad7051..7f67147a75580 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/search_strategy.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/search_strategy.ts @@ -16,6 +16,7 @@ import { import type { SearchServiceParams, + SearchServiceRawResponse, SearchServiceValue, } from '../../../../common/search_strategies/correlations/types'; @@ -100,20 +101,22 @@ export const apmCorrelationsSearchStrategyProvider = ( const took = Date.now() - started; + const rawResponse: SearchServiceRawResponse = { + ccsWarning, + log, + took, + values, + percentileThresholdValue, + overallHistogram, + }; + return of({ id, loaded, total, isRunning, isPartial: isRunning, - rawResponse: { - ccsWarning, - log, - took, - values, - percentileThresholdValue, - overallHistogram, - }, + rawResponse, }); }, cancel: async (id, options, deps) => {