[Security Solution][Threat Hunting] Monitor all http request using APM (#138152)

* integrate APM transactions to useSearchStrategy and useQuery hooks

* fix batched request transaction using blocking span

* disable transactions managed flag

* apm mock

* add and adapt tests

* add invalid response warning to useSearchStrategy

* make useQueryAlerts query name required and prop name changed

* fix tests

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sergi Massaneda 2022-08-30 12:50:53 +02:00 committed by GitHub
parent 62092e2e05
commit aae3ac0da7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 474 additions and 149 deletions

View file

@ -10,6 +10,7 @@ import type { Ecs } from '@kbn/cases-plugin/common';
import { useSourcererDataView } from '../../common/containers/sourcerer';
import { SourcererScopeName } from '../../common/store/sourcerer/model';
import { useQueryAlerts } from '../../detections/containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../detections/containers/detection_engine/alerts/constants';
import type { SignalHit } from '../../common/utils/alerts';
import { buildAlertsQuery, formatAlertToEcsSignal } from '../../common/utils/alerts';
@ -20,6 +21,7 @@ export const useFetchAlertData = (alertIds: string[]): [boolean, Record<string,
const { loading: isLoadingAlerts, data: alertsData } = useQueryAlerts<SignalHit, unknown>({
query: alertsQuery,
indexName: selectedPatterns[0],
queryName: ALERTS_QUERY_NAMES.CASES,
});
const alerts = useMemo(

View file

@ -18,6 +18,7 @@ import { AlertsTreemap, DEFAULT_MIN_CHART_HEIGHT } from '../alerts_treemap';
import { KpiPanel } from '../../../detections/components/alerts_kpis/common/components';
import { useInspectButton } from '../../../detections/components/alerts_kpis/common/hooks';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../../detections/containers/detection_engine/alerts/constants';
import { FieldSelection } from '../field_selection';
import { HeaderSection } from '../header_section';
import { InspectButtonContainer } from '../inspect';
@ -117,6 +118,7 @@ const AlertsTreemapPanelComponent: React.FC<Props> = ({
}),
skip: !isPanelExpanded,
indexName: signalIndexName,
queryName: ALERTS_QUERY_NAMES.TREE_MAP,
});
useEffect(() => {

View file

@ -11,6 +11,7 @@ import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../common/constants';
import { useGlobalTime } from '../use_global_time';
import type { GenericBuckets } from '../../../../common/search_strategy';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../../detections/containers/detection_engine/alerts/constants';
import { TimelineId } from '../../../../common/types';
import { useDeepEqualSelector } from '../../hooks/use_selector';
import { inputsSelectors } from '../../store';
@ -52,6 +53,7 @@ export const useAlertPrevalence = ({
const { loading, data, setQuery } = useQueryAlerts<{ _id: string }, AlertPrevalenceAggregation>({
query: initialQuery,
indexName: signalIndexName,
queryName: ALERTS_QUERY_NAMES.PREVALENCE,
});
useEffect(() => {

View file

@ -8,6 +8,7 @@
import { renderHook } from '@testing-library/react-hooks';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../../detections/containers/detection_engine/alerts/constants';
import { useAlertsByIds } from './use_alerts_by_ids';
jest.mock('../../../detections/containers/detection_engine/alerts/use_query', () => ({
@ -71,6 +72,7 @@ describe('useAlertsByIds', () => {
renderHook(() => useAlertsByIds({ alertIds }));
expect(mockUseQueryAlerts).toHaveBeenCalledWith({
queryName: ALERTS_QUERY_NAMES.BY_ID,
query: expect.objectContaining({
fields: ['*'],
_source: false,
@ -93,6 +95,7 @@ describe('useAlertsByIds', () => {
renderHook(() => useAlertsByIds({ alertIds, fields: testFields }));
expect(mockUseQueryAlerts).toHaveBeenCalledWith({
queryName: ALERTS_QUERY_NAMES.BY_ID,
query: expect.objectContaining({ fields: testFields }),
});
});

View file

@ -8,6 +8,7 @@
import { useEffect, useState } from 'react';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../../detections/containers/detection_engine/alerts/constants';
interface UseAlertByIdsOptions {
alertIds: string[];
@ -37,6 +38,7 @@ export const useAlertsByIds = ({
const { loading, data, setQuery } = useQueryAlerts<Hit, unknown>({
query: initialQuery,
queryName: ALERTS_QUERY_NAMES.BY_ID,
});
useEffect(() => {

View file

@ -5,21 +5,66 @@
* 2.0.
*/
import { useSearchStrategy } from '.';
import { useSearch, useSearchStrategy } from '.';
import { act, renderHook } from '@testing-library/react-hooks';
import { useObservable } from '@kbn/securitysolution-hook-utils';
import type { FactoryQueryTypes } from '../../../../common/search_strategy';
import type { FactoryQueryTypes, StrategyRequestType } from '../../../../common/search_strategy';
import { Observable } from 'rxjs';
jest.mock('@kbn/securitysolution-hook-utils');
const mockAddToastError = jest.fn();
const mockAddToastWarning = jest.fn();
jest.mock('../../hooks/use_app_toasts', () => ({
useAppToasts: jest.fn(() => ({
addError: mockAddToastError,
addWarning: mockAddToastWarning,
})),
}));
jest.mock('@kbn/securitysolution-hook-utils');
// default to completed response
const mockResponse = jest.fn(
() =>
({
rawResponse: {},
isPartial: false,
isRunning: false,
} as unknown)
);
const mockSearch = jest.fn(
() =>
new Observable((subscription) => {
subscription.next(mockResponse());
})
);
jest.mock('../../lib/kibana', () => {
const original = jest.requireActual('../../lib/kibana');
return {
...original,
useKibana: () => ({
...original.useKibana(),
services: {
...original.useKibana().services,
data: {
search: {
search: mockSearch,
},
},
},
}),
};
});
const mockEndTracking = jest.fn();
const mockStartTracking = jest.fn(() => ({
endTracking: mockEndTracking,
}));
jest.mock('../../lib/apm/use_track_http_request', () => ({
useTrackHttpRequest: () => ({ startTracking: mockStartTracking }),
}));
const mockAbortController = new AbortController();
mockAbortController.abort = jest.fn();
const useObservableHookResult = {
start: jest.fn(),
@ -28,13 +73,24 @@ const useObservableHookResult = {
loading: false,
};
const factoryQueryType = 'testFactoryQueryType' as FactoryQueryTypes;
const userSearchStrategyProps = {
factoryQueryType: 'testFactoryQueryType' as FactoryQueryTypes,
factoryQueryType,
initialResult: {},
errorMessage: 'testErrorMessage',
};
const request = {
fake: 'request',
search: 'parameters',
} as unknown as StrategyRequestType<FactoryQueryTypes>;
describe('useSearchStrategy', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(window, 'AbortController').mockRestore();
});
it("returns the provided initial result while the query hasn't returned data", () => {
const initialResult = {};
(useObservable as jest.Mock).mockReturnValue(useObservableHookResult);
@ -46,22 +102,17 @@ describe('useSearchStrategy', () => {
expect(result.current.result).toBe(initialResult);
});
it('calls start with the given factoryQueryType', () => {
const factoryQueryType = 'fakeQueryType' as FactoryQueryTypes;
it('calls start with the given request', () => {
const start = jest.fn();
(useObservable as jest.Mock).mockReturnValue({ ...useObservableHookResult, start });
const { result } = renderHook(() =>
useSearchStrategy<FactoryQueryTypes>({
...userSearchStrategyProps,
factoryQueryType,
})
useSearchStrategy<FactoryQueryTypes>(userSearchStrategyProps)
);
result.current.search({});
expect(start).toBeCalledWith(expect.objectContaining({ factoryQueryType }));
result.current.search(request);
expect(start).toBeCalledWith(expect.objectContaining({ request }));
});
it('returns inspect', () => {
@ -104,7 +155,6 @@ describe('useSearchStrategy', () => {
it('start should be called when search is called ', () => {
const start = jest.fn();
const searchParams = {};
(useObservable as jest.Mock).mockReturnValue({ ...useObservableHookResult, start });
@ -112,14 +162,13 @@ describe('useSearchStrategy', () => {
useSearchStrategy<FactoryQueryTypes>(userSearchStrategyProps)
);
result.current.search(searchParams);
result.current.search(request);
expect(start).toBeCalled();
});
it('refetch should execute the previous search again with the same params', async () => {
const start = jest.fn();
const searchParams = {};
(useObservable as jest.Mock).mockReturnValue({ ...useObservableHookResult, start });
@ -127,7 +176,7 @@ describe('useSearchStrategy', () => {
useSearchStrategy<FactoryQueryTypes>(userSearchStrategyProps)
);
result.current.search(searchParams);
result.current.search(request);
rerender();
@ -138,11 +187,7 @@ describe('useSearchStrategy', () => {
});
it('aborts previous search when a subsequent search is triggered', async () => {
const searchParams = {};
const abortFunction = jest.fn();
jest
.spyOn(window, 'AbortController')
.mockReturnValue({ abort: abortFunction, signal: {} as AbortSignal });
jest.spyOn(window, 'AbortController').mockReturnValue(mockAbortController);
(useObservable as jest.Mock).mockReturnValue(useObservableHookResult);
@ -150,18 +195,14 @@ describe('useSearchStrategy', () => {
useSearchStrategy<FactoryQueryTypes>(userSearchStrategyProps)
);
result.current.search(searchParams);
result.current.search(searchParams);
result.current.search(request);
result.current.search(request);
expect(abortFunction).toBeCalledTimes(2);
expect(mockAbortController.abort).toBeCalledTimes(2);
});
it('aborts search when component unmounts', async () => {
const searchParams = {};
const abortFunction = jest.fn();
jest
.spyOn(window, 'AbortController')
.mockReturnValue({ abort: abortFunction, signal: {} as AbortSignal });
jest.spyOn(window, 'AbortController').mockReturnValue(mockAbortController);
(useObservable as jest.Mock).mockReturnValue(useObservableHookResult);
@ -169,44 +210,95 @@ describe('useSearchStrategy', () => {
useSearchStrategy<FactoryQueryTypes>(userSearchStrategyProps)
);
result.current.search(searchParams);
result.current.search(request);
unmount();
expect(abortFunction).toBeCalledTimes(2);
expect(mockAbortController.abort).toBeCalledTimes(2);
});
it('calls start with the AbortController signal', () => {
const factoryQueryType = 'fakeQueryType' as FactoryQueryTypes;
jest.spyOn(window, 'AbortController').mockReturnValue(mockAbortController);
const start = jest.fn();
const signal = new AbortController().signal;
jest.spyOn(window, 'AbortController').mockReturnValue({ abort: jest.fn(), signal });
(useObservable as jest.Mock).mockReturnValue({ ...useObservableHookResult, start });
const { result } = renderHook(() =>
useSearchStrategy<FactoryQueryTypes>({
...userSearchStrategyProps,
factoryQueryType,
})
useSearchStrategy<FactoryQueryTypes>(userSearchStrategyProps)
);
result.current.search({});
result.current.search(request);
expect(start).toBeCalledWith(expect.objectContaining({ signal }));
expect(start).toBeCalledWith(
expect.objectContaining({ abortSignal: mockAbortController.signal })
);
});
it('abort = true will cancel any running request', () => {
const abortSpy = jest.fn();
const signal = new AbortController().signal;
jest.spyOn(window, 'AbortController').mockReturnValue({ abort: abortSpy, signal });
const factoryQueryType = 'fakeQueryType' as FactoryQueryTypes;
const localProps = {
...userSearchStrategyProps,
abort: false,
factoryQueryType,
};
jest.spyOn(window, 'AbortController').mockReturnValue(mockAbortController);
const localProps = { ...userSearchStrategyProps, abort: false };
const { rerender } = renderHook(() => useSearchStrategy<FactoryQueryTypes>(localProps));
localProps.abort = true;
act(() => rerender());
expect(abortSpy).toHaveBeenCalledTimes(1);
expect(mockAbortController.abort).toHaveBeenCalledTimes(1);
});
describe('search function', () => {
it('should track successful search result', () => {
const { result } = renderHook(() => useSearch<FactoryQueryTypes>(factoryQueryType));
result.current({ request, abortSignal: new AbortController().signal });
expect(mockStartTracking).toBeCalledTimes(1);
expect(mockEndTracking).toBeCalledTimes(1);
expect(mockEndTracking).toBeCalledWith('success');
});
it('should track invalid search result', () => {
mockResponse.mockReturnValueOnce({}); // mock invalid empty response
const { result } = renderHook(() => useSearch<FactoryQueryTypes>(factoryQueryType));
result.current({ request, abortSignal: new AbortController().signal });
expect(mockStartTracking).toBeCalledTimes(1);
expect(mockEndTracking).toBeCalledWith('invalid');
});
it('should track error search result', () => {
mockResponse.mockImplementationOnce(() => {
throw Error('fake server error');
});
const { result } = renderHook(() => useSearch<FactoryQueryTypes>(factoryQueryType));
result.current({ request, abortSignal: new AbortController().signal });
expect(mockStartTracking).toBeCalledTimes(1);
expect(mockEndTracking).toBeCalledTimes(1);
expect(mockEndTracking).toBeCalledWith('error');
});
it('should track aborted search result', () => {
const abortController = new AbortController();
mockResponse.mockImplementationOnce(() => {
abortController.abort();
throw Error('fake aborted');
});
const { result } = renderHook(() => useSearch<FactoryQueryTypes>(factoryQueryType));
result.current({ request, abortSignal: abortController.signal });
expect(mockStartTracking).toBeCalledTimes(1);
expect(mockEndTracking).toBeCalledTimes(1);
expect(mockEndTracking).toBeCalledWith('aborted');
});
it('should show toast warning when the API returns partial invalid response', () => {
mockResponse.mockReturnValueOnce({}); // mock invalid empty response
const { result } = renderHook(() => useSearch<FactoryQueryTypes>(factoryQueryType));
result.current({ request, abortSignal: new AbortController().signal });
expect(mockAddToastWarning).toBeCalled();
});
});
});

View file

@ -8,19 +8,13 @@ import { filter } from 'rxjs/operators';
import { noop, omit } from 'lodash/fp';
import { useCallback, useEffect, useRef, useMemo } from 'react';
import type { Observable } from 'rxjs';
import type { OptionalSignalArgs } from '@kbn/securitysolution-hook-utils';
import { useObservable } from '@kbn/securitysolution-hook-utils';
import type { IKibanaSearchResponse } from '@kbn/data-plugin/common';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { isCompleteResponse, isErrorResponse } from '@kbn/data-plugin/public';
import { AbortError } from '@kbn/kibana-utils-plugin/common';
import * as i18n from './translations';
import type {
FactoryQueryTypes,
RequestBasicOptions,
StrategyRequestType,
StrategyResponseType,
} from '../../../../common/search_strategy/security_solution';
@ -28,52 +22,73 @@ import { getInspectResponse } from '../../../helpers';
import type { inputsModel } from '../../store';
import { useKibana } from '../../lib/kibana';
import { useAppToasts } from '../../hooks/use_app_toasts';
import { useTrackHttpRequest } from '../../lib/apm/use_track_http_request';
import { APP_UI_ID } from '../../../../common/constants';
type UseSearchStrategyRequestArgs = RequestBasicOptions & {
data: DataPublicPluginStart;
signal: AbortSignal;
factoryQueryType: FactoryQueryTypes;
};
interface UseSearchFunctionParams<QueryType extends FactoryQueryTypes> {
request: StrategyRequestType<QueryType>;
abortSignal: AbortSignal;
}
const search = <ResponseType extends IKibanaSearchResponse>({
data,
signal,
factoryQueryType,
defaultIndex,
filterQuery,
timerange,
...requestProps
}: UseSearchStrategyRequestArgs): Observable<ResponseType> => {
return data.search.search<RequestBasicOptions, ResponseType>(
{
...requestProps,
factoryQueryType,
defaultIndex,
timerange,
filterQuery,
},
{
strategy: 'securitySolutionSearchStrategy',
abortSignal: signal,
}
);
};
type UseSearchFunction<QueryType extends FactoryQueryTypes> = (
params: UseSearchFunctionParams<QueryType>
) => Observable<StrategyResponseType<QueryType>>;
const searchComplete = <ResponseType extends IKibanaSearchResponse>(
props: UseSearchStrategyRequestArgs
): Observable<ResponseType> => {
return search<ResponseType>(props).pipe(
filter((response) => {
return isErrorResponse(response) || isCompleteResponse(response);
})
);
};
type SearchFunction<QueryType extends FactoryQueryTypes> = (
params: StrategyRequestType<QueryType>
) => void;
const EMPTY_INSPECT = {
dsl: [],
response: [],
};
export const useSearch = <QueryType extends FactoryQueryTypes>(
factoryQueryType: QueryType
): UseSearchFunction<QueryType> => {
const { data } = useKibana().services;
const { addWarning } = useAppToasts();
const { startTracking } = useTrackHttpRequest();
const search = useCallback<UseSearchFunction<QueryType>>(
({ abortSignal, request }) => {
const { endTracking } = startTracking({
name: `${APP_UI_ID} searchStrategy ${factoryQueryType}`,
spanName: 'batched search',
});
const observable = data.search
.search<StrategyRequestType<QueryType>, StrategyResponseType<QueryType>>(
{ ...request, factoryQueryType },
{
strategy: 'securitySolutionSearchStrategy',
abortSignal,
}
)
.pipe(filter((response) => isErrorResponse(response) || isCompleteResponse(response)));
observable.subscribe({
next: (response) => {
if (isErrorResponse(response)) {
addWarning(i18n.INVALID_RESPONSE_WARNING_SEARCH_STRATEGY(factoryQueryType));
endTracking('invalid');
} else {
endTracking('success');
}
},
error: () => {
endTracking(abortSignal.aborted ? 'aborted' : 'error');
},
});
return observable;
},
[addWarning, data.search, factoryQueryType, startTracking]
);
return search;
};
export const useSearchStrategy = <QueryType extends FactoryQueryTypes>({
factoryQueryType,
initialResult,
@ -86,7 +101,7 @@ export const useSearchStrategy = <QueryType extends FactoryQueryTypes>({
*/
initialResult: Omit<StrategyResponseType<QueryType>, 'rawResponse'>;
/**
* Message displayed to the user on a Toast when an erro happens.
* Message displayed to the user on a Toast when an error happens.
*/
errorMessage?: string;
/**
@ -95,15 +110,15 @@ export const useSearchStrategy = <QueryType extends FactoryQueryTypes>({
abort?: boolean;
}) => {
const abortCtrl = useRef(new AbortController());
const refetch = useRef<inputsModel.Refetch>(noop);
const { data } = useKibana().services;
const { addError } = useAppToasts();
const search = useSearch(factoryQueryType);
const { start, error, result, loading } = useObservable<
[UseSearchStrategyRequestArgs],
[UseSearchFunctionParams<QueryType>],
StrategyResponseType<QueryType>
>(searchComplete);
>(search);
useEffect(() => {
if (error != null && !(error instanceof AbortError)) {
@ -113,24 +128,22 @@ export const useSearchStrategy = <QueryType extends FactoryQueryTypes>({
}
}, [addError, error, errorMessage, factoryQueryType]);
const searchCb = useCallback(
(props: OptionalSignalArgs<StrategyRequestType<QueryType>>) => {
const asyncSearch = () => {
const searchCb = useCallback<SearchFunction<QueryType>>(
(request) => {
const startSearch = () => {
abortCtrl.current = new AbortController();
start({
...props,
data,
factoryQueryType,
signal: abortCtrl.current.signal,
} as never); // This typescast is required because every StrategyRequestType instance has different fields.
request,
abortSignal: abortCtrl.current.signal,
});
};
abortCtrl.current.abort();
asyncSearch();
startSearch();
refetch.current = asyncSearch;
refetch.current = startSearch;
},
[data, start, factoryQueryType]
[start]
);
useEffect(() => {
@ -145,19 +158,19 @@ export const useSearchStrategy = <QueryType extends FactoryQueryTypes>({
}
}, [abort]);
const [formatedResult, inspect] = useMemo(
() => [
result
? omit<StrategyResponseType<QueryType>, 'rawResponse'>('rawResponse', result)
: initialResult,
result ? getInspectResponse(result, EMPTY_INSPECT) : EMPTY_INSPECT,
],
[result, initialResult]
);
const [formattedResult, inspect] = useMemo(() => {
if (isErrorResponse(result)) {
return [initialResult, EMPTY_INSPECT];
}
return [
omit<StrategyResponseType<QueryType>, 'rawResponse'>('rawResponse', result),
getInspectResponse(result, EMPTY_INSPECT),
];
}, [result, initialResult]);
return {
loading,
result: formatedResult,
result: formattedResult,
error,
search: searchCb,
refetch: refetch.current,

View file

@ -13,3 +13,9 @@ export const DEFAULT_ERROR_SEARCH_STRATEGY = (factoryQueryType: FactoryQueryType
values: { factoryQueryType },
defaultMessage: `Failed to run search: {factoryQueryType}`,
});
export const INVALID_RESPONSE_WARNING_SEARCH_STRATEGY = (factoryQueryType: FactoryQueryTypes) =>
i18n.translate('xpack.securitySolution.searchStrategy.warning', {
values: { factoryQueryType },
defaultMessage: `An error has occurred running search: {factoryQueryType}`,
});

View file

@ -0,0 +1,11 @@
/*
* 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.
*/
const mockStartTracking = jest.fn(() => ({ endTracking: jest.fn() }));
export const useTrackHttpRequest = jest.fn(() => ({
startTracking: mockStartTracking,
}));

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const mockApm = () => ({
startTransaction: jest.fn(() => ({
addLabels: jest.fn(),
end: jest.fn(),
startSpan: jest.fn(() => ({
end: jest.fn(),
})),
})),
});

View file

@ -6,13 +6,15 @@
*/
import { useCallback } from 'react';
import type { TransactionOptions } from '@elastic/apm-rum';
import { useKibana } from '../kibana';
const transactionOptions = { managed: true };
const DEFAULT_TRANSACTION_OPTIONS: TransactionOptions = { managed: true };
interface StartTransactionOptions {
name: string;
type?: string;
options?: TransactionOptions;
}
export const useStartTransaction = () => {
@ -21,8 +23,8 @@ export const useStartTransaction = () => {
} = useKibana();
const startTransaction = useCallback(
({ name, type = 'user-interaction' }: StartTransactionOptions) => {
return apm.startTransaction(name, type, transactionOptions);
({ name, type = 'user-interaction', options }: StartTransactionOptions) => {
return apm.startTransaction(name, type, options ?? DEFAULT_TRANSACTION_OPTIONS);
},
[apm]
);

View file

@ -0,0 +1,46 @@
/*
* 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 { useCallback } from 'react';
import { useStartTransaction } from './use_start_transaction';
interface UseTrackHttpRequestOptions {
name: string;
spanName?: string;
}
export type RequestResult = 'success' | 'error' | 'aborted' | 'invalid';
export const useTrackHttpRequest = () => {
const { startTransaction } = useStartTransaction();
const startTracking = useCallback(
({ name, spanName = 'fetch' }: UseTrackHttpRequestOptions) => {
// Create the transaction, the managed flag is turned off to prevent it from being polluted by non-related automatic spans.
// The managed flag can be turned on to investigate high latency requests in APM.
// However, note that by enabling the managed flag, the transaction trace may be distorted by other requests information.
const transaction = startTransaction({
name,
type: 'http-request',
options: { managed: false },
});
// Create a blocking span to control the transaction time and prevent it from closing automatically with partial batch responses.
// The blocking span needs to be ended manually when the request finishes.
const span = transaction?.startSpan(spanName, 'http-request', {
blocking: true,
});
return {
endTracking: (result: RequestResult): void => {
transaction?.addLabels({ result });
span?.end();
},
};
},
[startTransaction]
);
return { startTracking };
};

View file

@ -16,6 +16,7 @@ import {
createStartServicesMock,
createWithKibanaMock,
} from '../kibana_react.mock';
import { mockApm } from '../../apm/service.mock';
import { APP_UI_ID } from '../../../../../common/constants';
import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
@ -24,6 +25,7 @@ export const KibanaServices = { get: jest.fn(), getKibanaVersion: jest.fn(() =>
export const useKibana = jest.fn().mockReturnValue({
services: {
...mockStartServicesMock,
apm: mockApm(),
uiSettings: {
get: jest.fn(),
set: jest.fn(),

View file

@ -8,7 +8,6 @@
/* eslint-disable react/display-name */
import React from 'react';
import type { RecursivePartial } from '@elastic/eui/src/components/common';
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
import { coreMock, themeServiceMock } from '@kbn/core/public/mocks';
@ -43,6 +42,7 @@ import { fleetMock } from '@kbn/fleet-plugin/public/mocks';
import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
import { noCasesPermissions } from '../../../cases_test_utils';
import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks';
import { mockApm } from '../apm/service.mock';
const mockUiSettings: Record<string, unknown> = {
[DEFAULT_TIME_RANGE]: { from: 'now-15m', to: 'now', mode: 'quick' },
@ -94,6 +94,7 @@ export const createStartServicesMock = (
): StartServices => {
core.uiSettings.get.mockImplementation(createUseUiSettingMock());
const { storage } = createSecuritySolutionStorageMock();
const apm = mockApm();
const data = dataPluginMock.createStartContract();
const security = securityMock.createSetup();
const urlService = new MockUrlService();
@ -106,6 +107,7 @@ export const createStartServicesMock = (
return {
...core,
apm,
cases,
unifiedSearch,
data: {

View file

@ -16,6 +16,7 @@ import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { HeaderSection } from '../../../../common/components/header_section';
import { useQueryAlerts } from '../../../containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../../containers/detection_engine/alerts/constants';
import { InspectButtonContainer } from '../../../../common/components/inspect';
import { getAlertsCountQuery } from './helpers';
@ -125,6 +126,7 @@ export const AlertsCountPanel = memo<AlertsCountPanelProps>(
}),
indexName: signalIndexName,
skip: querySkip,
queryName: ALERTS_QUERY_NAMES.COUNT,
});
useEffect(() => {

View file

@ -25,6 +25,7 @@ import type { LegendItem } from '../../../../common/components/charts/draggable_
import { escapeDataProviderId } from '../../../../common/components/drag_and_drop/helpers';
import { HeaderSection } from '../../../../common/components/header_section';
import { useQueryAlerts } from '../../../containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../../containers/detection_engine/alerts/constants';
import { getDetectionEngineUrl, useFormatUrl } from '../../../../common/components/link_to';
import { defaultLegendColors } from '../../../../common/components/matrix_histogram/utils';
import { InspectButtonContainer } from '../../../../common/components/inspect';
@ -191,6 +192,7 @@ export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
),
indexName: signalIndexName,
skip: querySkip,
queryName: ALERTS_QUERY_NAMES.HISTOGRAM,
});
const kibana = useKibana();

View file

@ -29,6 +29,7 @@ import { inputsSelectors } from '../../../../common/store';
import { TimelineId } from '../../../../../common/types';
import type { AlertData, EcsHit } from '../../../../common/components/exceptions/types';
import { useQueryAlerts } from '../../../containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../../containers/detection_engine/alerts/constants';
import { useSignalIndex } from '../../../containers/detection_engine/alerts/use_signal_index';
import { EventFiltersFlyout } from '../../../../management/pages/event_filters/view/components/event_filters_flyout';
import { useAlertsActions } from './use_alerts_actions';
@ -315,6 +316,7 @@ export const AddExceptionFlyoutWrapper: React.FC<AddExceptionFlyoutWrapperProps>
const { loading: isLoadingAlertData, data } = useQueryAlerts<EcsHit, {}>({
query: buildGetAlertByIdQuery(eventId),
indexName: signalIndexName,
queryName: ALERTS_QUERY_NAMES.ADD_EXCEPTION_FLYOUT,
});
const enrichedAlert: AlertData | undefined = useMemo(() => {

View file

@ -0,0 +1,23 @@
/*
* 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 { APP_UI_ID } from '../../../../../common/constants';
export const ALERTS_QUERY_NAMES = {
ADD_EXCEPTION_FLYOUT: `${APP_UI_ID} fetchAlerts addExceptionFlyout`,
BY_ID: `${APP_UI_ID} fetchAlerts byId`,
BY_RULE_ID: `${APP_UI_ID} fetchAlerts byRuleId`,
BY_SEVERITY: `${APP_UI_ID} fetchAlerts bySeverity`,
BY_STATUS: `${APP_UI_ID} fetchAlerts byStatus`,
CASES: `${APP_UI_ID} fetchAlerts cases`,
COUNT: `${APP_UI_ID} fetchAlerts count`,
HISTOGRAM: `${APP_UI_ID} fetchAlerts histogram`,
PREVALENCE: `${APP_UI_ID} fetchAlerts prevalence`,
TREE_MAP: `${APP_UI_ID} fetchAlerts treeMap`,
VULNERABLE_HOSTS: `${APP_UI_ID} fetchAlerts vulnerableHosts`,
VULNERABLE_USERS: `${APP_UI_ID} fetchAlerts vulnerableUsers`,
} as const;

View file

@ -8,22 +8,31 @@
import { renderHook, act } from '@testing-library/react-hooks';
import type { ReturnQueryAlerts } from './use_query';
import { useQueryAlerts } from './use_query';
import { ALERTS_QUERY_NAMES } from './constants';
import * as api from './api';
import { mockAlertsQuery, alertsMock } from './mock';
jest.mock('./api');
jest.mock('../../../../common/lib/apm/use_track_http_request');
const indexName = 'mock-index-name';
const defaultProps = {
query: mockAlertsQuery,
indexName,
queryName: ALERTS_QUERY_NAMES.COUNT,
};
describe('useQueryAlerts', () => {
const indexName = 'mock-index-name';
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
test('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<
[object, string],
ReturnQueryAlerts<unknown, unknown>
>(() => useQueryAlerts<unknown, unknown>({ query: mockAlertsQuery, indexName }));
>(() => useQueryAlerts<unknown, unknown>(defaultProps));
await waitForNextUpdate();
expect(result.current).toEqual({
loading: false,
@ -41,7 +50,7 @@ describe('useQueryAlerts', () => {
const { result, waitForNextUpdate } = renderHook<
[object, string],
ReturnQueryAlerts<unknown, unknown>
>(() => useQueryAlerts<unknown, unknown>({ query: mockAlertsQuery, indexName }));
>(() => useQueryAlerts<unknown, unknown>(defaultProps));
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
@ -61,7 +70,7 @@ describe('useQueryAlerts', () => {
const { result, waitForNextUpdate } = renderHook<
[object, string],
ReturnQueryAlerts<unknown, unknown>
>(() => useQueryAlerts<unknown, unknown>({ query: mockAlertsQuery, indexName }));
>(() => useQueryAlerts<unknown, unknown>(defaultProps));
await waitForNextUpdate();
await waitForNextUpdate();
if (result.current.refetch) {
@ -78,7 +87,7 @@ describe('useQueryAlerts', () => {
const { rerender, waitForNextUpdate } = renderHook<
[object, string],
ReturnQueryAlerts<unknown, unknown>
>((args) => useQueryAlerts({ query: args[0], indexName: args[1] }), {
>((args) => useQueryAlerts({ ...defaultProps, query: args[0], indexName: args[1] }), {
initialProps: [mockAlertsQuery, indexName],
});
await waitForNextUpdate();
@ -95,7 +104,7 @@ describe('useQueryAlerts', () => {
const { result, waitForNextUpdate } = renderHook<
[object, string],
ReturnQueryAlerts<unknown, unknown>
>((args) => useQueryAlerts({ query: args[0], indexName: args[1] }), {
>((args) => useQueryAlerts({ ...defaultProps, query: args[0], indexName: args[1] }), {
initialProps: [mockAlertsQuery, indexName],
});
await waitForNextUpdate();
@ -115,8 +124,7 @@ describe('useQueryAlerts', () => {
});
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, ReturnQueryAlerts<unknown, unknown>>(
() =>
useQueryAlerts<unknown, unknown>({ query: mockAlertsQuery, indexName: 'mock-index-name' })
() => useQueryAlerts<unknown, unknown>(defaultProps)
);
await waitForNextUpdate();
await waitForNextUpdate();
@ -134,7 +142,7 @@ describe('useQueryAlerts', () => {
test('skip', async () => {
const abortSpy = jest.spyOn(AbortController.prototype, 'abort');
await act(async () => {
const localProps = { query: mockAlertsQuery, indexName, skip: false };
const localProps = { ...defaultProps, skip: false };
const { rerender, waitForNextUpdate } = renderHook<
[object, string],
ReturnQueryAlerts<unknown, unknown>

View file

@ -6,13 +6,14 @@
*/
import { isEmpty } from 'lodash';
import { useMemo, useEffect, useState } from 'react';
import type { SetStateAction } from 'react';
import type React from 'react';
import { useEffect, useState } from 'react';
import type { fetchQueryRuleRegistryAlerts } from './api';
import { fetchQueryAlerts } from './api';
import type { AlertSearchResponse } from './types';
import type { AlertSearchResponse, QueryAlerts } from './types';
import { useTrackHttpRequest } from '../../../../common/lib/apm/use_track_http_request';
import type { ALERTS_QUERY_NAMES } from './constants';
type Func = () => Promise<void>;
@ -25,13 +26,45 @@ export interface ReturnQueryAlerts<Hit, Aggs> {
refetch: Func | null;
}
interface AlertsQueryParams {
fetchMethod?: typeof fetchQueryAlerts | typeof fetchQueryRuleRegistryAlerts;
type AlertsQueryName = typeof ALERTS_QUERY_NAMES[keyof typeof ALERTS_QUERY_NAMES];
type FetchMethod = typeof fetchQueryAlerts | typeof fetchQueryRuleRegistryAlerts;
export interface AlertsQueryParams {
fetchMethod?: FetchMethod;
query: object;
indexName?: string | null;
skip?: boolean;
/**
* The query name is used for performance monitoring with APM
*/
queryName: AlertsQueryName;
}
/**
* Wrapped `fetchMethod` hook that integrates
* http-request monitoring using APM transactions.
*/
const useTrackedFetchMethod = (fetchMethod: FetchMethod, queryName: string): FetchMethod => {
const { startTracking } = useTrackHttpRequest();
const monitoredFetchMethod = useMemo<FetchMethod>(() => {
return async <Hit, Aggs>(params: QueryAlerts) => {
const { endTracking } = startTracking({ name: queryName });
let result: AlertSearchResponse<Hit, Aggs>;
try {
result = await fetchMethod<Hit, Aggs>(params);
endTracking('success');
} catch (err) {
endTracking(params.signal.aborted ? 'aborted' : 'error');
throw err;
}
return result;
};
}, [fetchMethod, queryName, startTracking]);
return monitoredFetchMethod;
};
/**
* Hook for fetching Alerts from the Detection Engine API
*
@ -43,6 +76,7 @@ export const useQueryAlerts = <Hit, Aggs>({
query: initialQuery,
indexName,
skip,
queryName,
}: AlertsQueryParams): ReturnQueryAlerts<Hit, Aggs> => {
const [query, setQuery] = useState(initialQuery);
const [alerts, setAlerts] = useState<
@ -56,6 +90,8 @@ export const useQueryAlerts = <Hit, Aggs>({
});
const [loading, setLoading] = useState(false);
const fetchAlerts = useTrackedFetchMethod(fetchMethod, queryName);
useEffect(() => {
let isSubscribed = true;
const abortCtrl = new AbortController();
@ -64,7 +100,7 @@ export const useQueryAlerts = <Hit, Aggs>({
try {
setLoading(true);
const alertResponse = await fetchMethod<Hit, Aggs>({
const alertResponse = await fetchAlerts<Hit, Aggs>({
query,
signal: abortCtrl.signal,
});
@ -107,7 +143,7 @@ export const useQueryAlerts = <Hit, Aggs>({
isSubscribed = false;
abortCtrl.abort();
};
}, [query, indexName, skip, fetchMethod]);
}, [query, indexName, skip, fetchAlerts]);
return { loading, ...alerts };
};

View file

@ -18,6 +18,7 @@ import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
jest.mock('./api');
jest.mock('../alerts/api');
jest.mock('../../../../common/hooks/use_app_toasts');
jest.mock('../../../../common/lib/kibana');
const mockNotFoundErrorForRule = () => {
(api.fetchRuleById as jest.Mock).mockImplementation(async () => {

View file

@ -14,6 +14,7 @@ import { expandDottedObject } from '../../../../../common/utils/expand_dotted';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import type { AlertSearchResponse } from '../alerts/types';
import { useQueryAlerts } from '../alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../alerts/constants';
import { fetchRuleById } from './api';
import { transformInput } from './transforms';
import * as i18n from './translations';
@ -98,6 +99,7 @@ export const useRuleWithFallback = (ruleId: string): UseRuleWithFallback => {
const { loading: alertsLoading, data: alertsData } = useQueryAlerts<AlertHit, undefined>({
query: buildLastAlertQuery(ruleId),
skip: isExistingRule,
queryName: ALERTS_QUERY_NAMES.BY_RULE_ID,
});
useEffect(() => {

View file

@ -70,7 +70,6 @@ jest.mock('../../../common/components/visualization_actions', () => ({
describe('body', () => {
const scenariosMap = {
[HostsTableType.authentications]: 'AuthenticationsQueryTabBody',
[HostsTableType.hosts]: 'HostsQueryTabBody',
[HostsTableType.uncommonProcesses]: 'UncommonProcessQueryTabBody',
[HostsTableType.anomalies]: 'AnomaliesQueryTabBody',
[HostsTableType.events]: 'EventsQueryTabBody',

View file

@ -20,7 +20,6 @@ import type { HostDetailsTabsProps } from './types';
import { type } from './utils';
import {
HostsQueryTabBody,
AuthenticationsQueryTabBody,
UncommonProcessQueryTabBody,
HostRiskTabBody,
@ -62,9 +61,6 @@ export const HostDetailsTabs = React.memo<HostDetailsTabsProps>(
<Route path={`${hostDetailsPagePath}/:tabName(${HostsTableType.authentications})`}>
<AuthenticationsQueryTabBody {...tabProps} />
</Route>
<Route path={`${hostDetailsPagePath}/:tabName(${HostsTableType.hosts})`}>
<HostsQueryTabBody {...tabProps} />
</Route>
<Route path={`${hostDetailsPagePath}/:tabName(${HostsTableType.uncommonProcesses})`}>
<UncommonProcessQueryTabBody {...tabProps} />
</Route>

View file

@ -7,6 +7,7 @@
import { renderHook } from '@testing-library/react-hooks';
import { TestProviders } from '../../../../common/mock';
import { ALERTS_QUERY_NAMES } from '../../../../detections/containers/detection_engine/alerts/constants';
import { from, mockAlertsData, alertsByStatusQuery, parsedMockAlertsData, to } from './mock_data';
import type { UseAlertsByStatus, UseAlertsByStatusProps } from './use_alerts_by_status';
import { useAlertsByStatus } from './use_alerts_by_status';
@ -72,6 +73,7 @@ describe('useAlertsByStatus', () => {
query: alertsByStatusQuery,
indexName: 'signal-alerts',
skip: false,
queryName: ALERTS_QUERY_NAMES.BY_STATUS,
});
});
@ -117,6 +119,7 @@ describe('useAlertsByStatus', () => {
query: alertsByStatusQuery,
indexName: 'signal-alerts',
skip: true,
queryName: ALERTS_QUERY_NAMES.BY_STATUS,
});
expect(result.current).toEqual({

View file

@ -9,6 +9,7 @@ import { useCallback, useEffect, useState } from 'react';
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { useQueryAlerts } from '../../../../detections/containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../../../detections/containers/detection_engine/alerts/constants';
import { useQueryInspector } from '../../../../common/components/page/manage_query';
import type { AlertsByStatusAgg, AlertsByStatusResponse, ParsedAlertsData } from './types';
import {
@ -109,6 +110,7 @@ export const useAlertsByStatus: UseAlertsByStatus = ({
}),
indexName: signalIndexName,
skip,
queryName: ALERTS_QUERY_NAMES.BY_STATUS,
});
useEffect(() => {

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { ALERTS_QUERY_NAMES } from '../../../../detections/containers/detection_engine/alerts/constants';
import { buildVulnerableHostAggregationQuery } from './use_host_alerts_items';
export const mockVulnerableHostsBySeverityResult = {
@ -126,4 +127,5 @@ export const mockQuery = () => ({
}),
indexName: 'signal-alerts',
skip: false,
queryName: ALERTS_QUERY_NAMES.VULNERABLE_HOSTS,
});

View file

@ -11,6 +11,7 @@ import { useQueryInspector } from '../../../../common/components/page/manage_que
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import type { GenericBuckets } from '../../../../../common/search_strategy';
import { useQueryAlerts } from '../../../../detections/containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../../../detections/containers/detection_engine/alerts/constants';
import { getPageCount, ITEMS_PER_PAGE } from '../utils';
const HOSTS_BY_SEVERITY_AGG = 'hostsBySeverity';
@ -73,6 +74,7 @@ export const useHostAlertsItems: UseHostAlertsItems = ({ skip, queryId, signalIn
}),
indexName: signalIndexName,
skip,
queryName: ALERTS_QUERY_NAMES.VULNERABLE_HOSTS,
});
useEffect(() => {

View file

@ -6,6 +6,7 @@
*/
import { renderHook } from '@testing-library/react-hooks';
import { ALERTS_QUERY_NAMES } from '../../../../detections/containers/detection_engine/alerts/constants';
import {
from,
@ -74,6 +75,7 @@ describe('useRuleAlertsItems', () => {
query: severityRuleAlertsQuery,
indexName: 'signal-alerts',
skip: false,
queryName: ALERTS_QUERY_NAMES.BY_SEVERITY,
});
});
@ -118,6 +120,7 @@ describe('useRuleAlertsItems', () => {
query: severityRuleAlertsQuery,
indexName: 'signal-alerts',
skip: true,
queryName: ALERTS_QUERY_NAMES.BY_SEVERITY,
});
expect(result.current).toEqual({

View file

@ -9,6 +9,7 @@ import { useCallback, useEffect, useState } from 'react';
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { useQueryAlerts } from '../../../../detections/containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../../../detections/containers/detection_engine/alerts/constants';
import { useQueryInspector } from '../../../../common/components/page/manage_query';
// Formatted item result
@ -136,6 +137,7 @@ export const useRuleAlertsItems: UseRuleAlertsItems = ({
}),
indexName: signalIndexName,
skip,
queryName: ALERTS_QUERY_NAMES.BY_SEVERITY,
});
useEffect(() => {

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { ALERTS_QUERY_NAMES } from '../../../../detections/containers/detection_engine/alerts/constants';
import { buildVulnerableUserAggregationQuery } from './use_user_alerts_items';
export const mockVulnerableUsersBySeverityResult = {
@ -126,4 +127,5 @@ export const mockQuery = () => ({
}),
indexName: 'signal-alerts',
skip: false,
queryName: ALERTS_QUERY_NAMES.VULNERABLE_USERS,
});

View file

@ -11,6 +11,7 @@ import { useQueryInspector } from '../../../../common/components/page/manage_que
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import type { GenericBuckets } from '../../../../../common/search_strategy';
import { useQueryAlerts } from '../../../../detections/containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../../../detections/containers/detection_engine/alerts/constants';
import { getPageCount, ITEMS_PER_PAGE } from '../utils';
const USERS_BY_SEVERITY_AGG = 'usersBySeverity';
@ -73,6 +74,7 @@ export const useUserAlertsItems: UseUserAlertsItems = ({ skip, queryId, signalIn
}),
indexName: signalIndexName,
skip,
queryName: ALERTS_QUERY_NAMES.VULNERABLE_USERS,
});
useEffect(() => {

View file

@ -16,6 +16,7 @@ import type { DataView } from '@kbn/data-views-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { isCompleteResponse, isErrorResponse } from '@kbn/data-plugin/common';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import {
clearEventsLoading,
clearEventsDeleted,
@ -42,6 +43,7 @@ import type { KueryFilterQueryKind } from '../../common/types/timeline';
import { useAppToasts } from '../hooks/use_app_toasts';
import { TimelineId } from '../store/t_grid/types';
import * as i18n from './translations';
import { TimelinesStartPlugins } from '../types';
export type InspectResponse = Inspect & { response: string[] };
@ -115,6 +117,30 @@ export const initSortDefault = [
},
];
const useApmTracking = (timelineId: string) => {
const { apm } = useKibana<TimelinesStartPlugins>().services;
const startTracking = useCallback(() => {
// Create the transaction, the managed flag is turned off to prevent it from being polluted by non-related automatic spans.
// The managed flag can be turned on to investigate high latency requests in APM.
// However, note that by enabling the managed flag, the transaction trace may be distorted by other requests information.
const transaction = apm?.startTransaction(`Timeline search ${timelineId}`, 'http-request', {
managed: false,
});
// Create a blocking span to control the transaction time and prevent it from closing automatically with partial batch responses.
// The blocking span needs to be ended manually when the batched request finishes.
const span = transaction?.startSpan('batched search', 'http-request', { blocking: true });
return {
endTracking: (result: 'success' | 'error' | 'aborted' | 'invalid') => {
transaction?.addLabels({ result });
span?.end();
},
};
}, [apm, timelineId]);
return { startTracking };
};
const NO_CONSUMERS: AlertConsumers[] = [];
export const useTimelineEvents = ({
alertConsumers = NO_CONSUMERS,
@ -136,6 +162,7 @@ export const useTimelineEvents = ({
data,
}: UseTimelineEventsProps): [boolean, TimelineArgs] => {
const dispatch = useDispatch();
const { startTracking } = useApmTracking(id);
const refetch = useRef<Refetch>(noop);
const abortCtrl = useRef(new AbortController());
const searchSubscription$ = useRef(new Subscription());
@ -205,6 +232,9 @@ export const useTimelineEvents = ({
abortCtrl.current = new AbortController();
setLoading(true);
if (data && data.search) {
const { endTracking } = startTracking();
const abortSignal = abortCtrl.current.signal;
searchSubscription$.current = data.search
.search<TimelineRequest<typeof language>, TimelineResponse<typeof language>>(
{ ...request, entityType },
@ -213,7 +243,7 @@ export const useTimelineEvents = ({
request.language === 'eql'
? 'timelineEqlSearchStrategy'
: 'timelineSearchStrategy',
abortSignal: abortCtrl.current.signal,
abortSignal,
// we only need the id to throw better errors
indexPattern: { id: dataViewId } as unknown as DataView,
}
@ -221,6 +251,7 @@ export const useTimelineEvents = ({
.subscribe({
next: (response) => {
if (isCompleteResponse(response)) {
endTracking('success');
setTimelineResponse((prevResponse) => {
const newTimelineResponse = {
...prevResponse,
@ -238,12 +269,14 @@ export const useTimelineEvents = ({
searchSubscription$.current.unsubscribe();
} else if (isErrorResponse(response)) {
endTracking('invalid');
setLoading(false);
addWarning(i18n.ERROR_TIMELINE_EVENTS);
searchSubscription$.current.unsubscribe();
}
},
error: (msg) => {
endTracking(abortSignal.aborted ? 'aborted' : 'error');
setLoading(false);
data.search.showError(msg);
searchSubscription$.current.unsubscribe();
@ -257,7 +290,7 @@ export const useTimelineEvents = ({
asyncSearch();
refetch.current = asyncSearch;
},
[skip, data, entityType, dataViewId, setUpdated, addWarning]
[skip, data, entityType, dataViewId, setUpdated, addWarning, startTracking]
);
useEffect(() => {

View file

@ -12,6 +12,7 @@ import { CoreStart } from '@kbn/core/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { CasesUiStart } from '@kbn/cases-plugin/public';
import type { TriggersAndActionsUIPublicPluginStart as TriggersActionsStart } from '@kbn/triggers-actions-ui-plugin/public';
import { ApmBase } from '@elastic/apm-rum';
import type {
LastUpdatedAtProps,
LoadingPanelProps,
@ -46,6 +47,7 @@ export interface TimelinesStartPlugins {
data: DataPublicPluginStart;
cases: CasesUiStart;
triggersActionsUi: TriggersActionsStart;
apm?: ApmBase;
}
export type TimelinesStartServices = CoreStart & TimelinesStartPlugins;