[APM] Displays callout when transaction events are used instead of aggregrated metrics (#108080)

* [APM] Displays callout when transaction events are used instead of aggregrated metrics (#107477)

* Apply suggestions from code review

Co-authored-by: Søren Louv-Jansen <sorenlouv@gmail.com>

* PR feedback, and isolates the logic for getting the fallback strategy

* PR feedback

Co-authored-by: Søren Louv-Jansen <sorenlouv@gmail.com>
This commit is contained in:
Oliver Gupte 2021-08-11 09:11:29 -07:00 committed by GitHub
parent 947657118c
commit ae73cf8416
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 333 additions and 144 deletions

View file

@ -15,6 +15,8 @@ import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_
import { useUrlParams } from '../../../context/url_params_context/use_url_params';
import { useLocalStorage } from '../../../hooks/useLocalStorage';
import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher';
import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to_transactions_fetcher';
import { AggregatedTransactionsCallout } from '../../shared/aggregated_transactions_callout';
import { useUpgradeAssistantHref } from '../../shared/Links/kibana';
import { SearchBar } from '../../shared/search_bar';
import { getTimeRangeComparison } from '../../shared/time_comparison/get_time_range_comparison';
@ -155,6 +157,7 @@ function useServicesFetcher() {
export function ServiceInventory() {
const { core } = useApmPluginContext();
const { fallbackToTransactions } = useFallbackToTransactionsFetcher();
const {
servicesData,
servicesStatus,
@ -189,6 +192,11 @@ export function ServiceInventory() {
<MLCallout onDismiss={() => setUserHasDismissedCallout(true)} />
</EuiFlexItem>
)}
{fallbackToTransactions && (
<EuiFlexItem>
<AggregatedTransactionsCallout />
</EuiFlexItem>
)}
<EuiFlexItem>
<ServiceList
isLoading={isLoading}

View file

@ -98,143 +98,9 @@ describe('ServiceInventory', () => {
it('should render services, when list is not empty', async () => {
// mock rest requests
httpGet.mockResolvedValueOnce({
hasLegacyData: false,
hasHistoricalData: true,
items: [
{
serviceName: 'My Python Service',
agentName: 'python',
transactionsPerMinute: 100,
errorsPerMinute: 200,
avgResponseTime: 300,
environments: ['test', 'dev'],
healthStatus: ServiceHealthStatus.warning,
},
{
serviceName: 'My Go Service',
agentName: 'go',
transactionsPerMinute: 400,
errorsPerMinute: 500,
avgResponseTime: 600,
environments: [],
severity: ServiceHealthStatus.healthy,
},
],
});
const { container, findByText } = render(<ServiceInventory />, { wrapper });
// wait for requests to be made
await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(1));
await findByText('My Python Service');
expect(container.querySelectorAll('.euiTableRow')).toHaveLength(2);
});
it('should render getting started message, when list is empty and no historical data is found', async () => {
httpGet.mockResolvedValueOnce({
hasLegacyData: false,
hasHistoricalData: false,
items: [],
});
const { findByText } = render(<ServiceInventory />, { wrapper });
// wait for requests to be made
await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(1));
// wait for elements to be rendered
const gettingStartedMessage = await findByText(
"Looks like you don't have any APM services installed. Let's add some!"
);
expect(gettingStartedMessage).not.toBeEmptyDOMElement();
});
it('should render empty message, when list is empty and historical data is found', async () => {
httpGet.mockResolvedValueOnce({
hasLegacyData: false,
hasHistoricalData: true,
items: [],
});
const { findByText } = render(<ServiceInventory />, { wrapper });
// wait for requests to be made
await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(1));
const noServicesText = await findByText('No services found');
expect(noServicesText).not.toBeEmptyDOMElement();
});
describe('when legacy data is found', () => {
it('renders an upgrade migration notification', async () => {
httpGet.mockResolvedValueOnce({
hasLegacyData: true,
hasHistoricalData: true,
items: [],
});
render(<ServiceInventory />, { wrapper });
// wait for requests to be made
await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(1));
expect(addWarning).toHaveBeenLastCalledWith(
expect.objectContaining({
title: 'Legacy data was detected within the selected time range',
})
);
});
});
describe('when legacy data is not found', () => {
it('does not render an upgrade migration notification', async () => {
httpGet.mockResolvedValueOnce({
hasLegacyData: false,
hasHistoricalData: true,
items: [],
});
render(<ServiceInventory />, { wrapper });
// wait for requests to be made
await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(1));
expect(addWarning).not.toHaveBeenCalled();
});
});
describe('when ML data is not found', () => {
it('does not render the health column', async () => {
httpGet.mockResolvedValueOnce({
hasLegacyData: false,
hasHistoricalData: true,
items: [
{
serviceName: 'My Python Service',
agentName: 'python',
transactionsPerMinute: 100,
errorsPerMinute: 200,
avgResponseTime: 300,
environments: ['test', 'dev'],
},
],
});
const { queryByText } = render(<ServiceInventory />, { wrapper });
// wait for requests to be made
await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(1));
expect(queryByText('Health')).toBeNull();
});
});
describe('when ML data is found', () => {
it('renders the health column', async () => {
httpGet.mockResolvedValueOnce({
httpGet
.mockResolvedValueOnce({ fallbackToTransactions: false })
.mockResolvedValueOnce({
hasLegacyData: false,
hasHistoricalData: true,
items: [
@ -247,13 +113,161 @@ describe('ServiceInventory', () => {
environments: ['test', 'dev'],
healthStatus: ServiceHealthStatus.warning,
},
{
serviceName: 'My Go Service',
agentName: 'go',
transactionsPerMinute: 400,
errorsPerMinute: 500,
avgResponseTime: 600,
environments: [],
severity: ServiceHealthStatus.healthy,
},
],
});
const { container, findByText } = render(<ServiceInventory />, { wrapper });
// wait for requests to be made
await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(2));
await findByText('My Python Service');
expect(container.querySelectorAll('.euiTableRow')).toHaveLength(2);
});
it('should render getting started message, when list is empty and no historical data is found', async () => {
httpGet
.mockResolvedValueOnce({ fallbackToTransactions: false })
.mockResolvedValueOnce({
hasLegacyData: false,
hasHistoricalData: false,
items: [],
});
const { findByText } = render(<ServiceInventory />, { wrapper });
// wait for requests to be made
await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(2));
// wait for elements to be rendered
const gettingStartedMessage = await findByText(
"Looks like you don't have any APM services installed. Let's add some!"
);
expect(gettingStartedMessage).not.toBeEmptyDOMElement();
});
it('should render empty message, when list is empty and historical data is found', async () => {
httpGet
.mockResolvedValueOnce({ fallbackToTransactions: false })
.mockResolvedValueOnce({
hasLegacyData: false,
hasHistoricalData: true,
items: [],
});
const { findByText } = render(<ServiceInventory />, { wrapper });
// wait for requests to be made
await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(2));
const noServicesText = await findByText('No services found');
expect(noServicesText).not.toBeEmptyDOMElement();
});
describe('when legacy data is found', () => {
it('renders an upgrade migration notification', async () => {
httpGet
.mockResolvedValueOnce({ fallbackToTransactions: false })
.mockResolvedValueOnce({
hasLegacyData: true,
hasHistoricalData: true,
items: [],
});
render(<ServiceInventory />, { wrapper });
// wait for requests to be made
await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(2));
expect(addWarning).toHaveBeenLastCalledWith(
expect.objectContaining({
title: 'Legacy data was detected within the selected time range',
})
);
});
});
describe('when legacy data is not found', () => {
it('does not render an upgrade migration notification', async () => {
httpGet
.mockResolvedValueOnce({ fallbackToTransactions: false })
.mockResolvedValueOnce({
hasLegacyData: false,
hasHistoricalData: true,
items: [],
});
render(<ServiceInventory />, { wrapper });
// wait for requests to be made
await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(2));
expect(addWarning).not.toHaveBeenCalled();
});
});
describe('when ML data is not found', () => {
it('does not render the health column', async () => {
httpGet
.mockResolvedValueOnce({ fallbackToTransactions: false })
.mockResolvedValueOnce({
hasLegacyData: false,
hasHistoricalData: true,
items: [
{
serviceName: 'My Python Service',
agentName: 'python',
transactionsPerMinute: 100,
errorsPerMinute: 200,
avgResponseTime: 300,
environments: ['test', 'dev'],
},
],
});
const { queryByText } = render(<ServiceInventory />, { wrapper });
// wait for requests to be made
await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(2));
expect(queryByText('Health')).toBeNull();
});
});
describe('when ML data is found', () => {
it('renders the health column', async () => {
httpGet
.mockResolvedValueOnce({ fallbackToTransactions: false })
.mockResolvedValueOnce({
hasLegacyData: false,
hasHistoricalData: true,
items: [
{
serviceName: 'My Python Service',
agentName: 'python',
transactionsPerMinute: 100,
errorsPerMinute: 200,
avgResponseTime: 300,
environments: ['test', 'dev'],
healthStatus: ServiceHealthStatus.warning,
},
],
});
const { queryAllByText } = render(<ServiceInventory />, { wrapper });
// wait for requests to be made
await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(1));
await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(2));
expect(queryAllByText('Health').length).toBeGreaterThan(1);
});

View file

@ -20,6 +20,8 @@ import { ServiceOverviewErrorsTable } from './service_overview_errors_table';
import { ServiceOverviewInstancesChartAndTable } from './service_overview_instances_chart_and_table';
import { ServiceOverviewThroughputChart } from './service_overview_throughput_chart';
import { TransactionsTable } from '../../shared/transactions_table';
import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to_transactions_fetcher';
import { AggregatedTransactionsCallout } from '../../shared/aggregated_transactions_callout';
/**
* The height a chart should be if it's next to a table with 5 rows and a title.
@ -28,6 +30,7 @@ import { TransactionsTable } from '../../shared/transactions_table';
export const chartHeight = 288;
export function ServiceOverview() {
const { fallbackToTransactions } = useFallbackToTransactionsFetcher();
const { agentName, serviceName } = useApmServiceContext();
// The default EuiFlexGroup breaks at 768, but we want to break at 992, so we
@ -41,6 +44,11 @@ export function ServiceOverview() {
<AnnotationsContextProvider>
<ChartPointerEventContextProvider>
<EuiFlexGroup direction="column" gutterSize="s">
{fallbackToTransactions && (
<EuiFlexItem>
<AggregatedTransactionsCallout />
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiPanel hasBorder={true}>
<LatencyChart height={200} />

View file

@ -140,6 +140,9 @@ describe('ServiceOverview', () => {
'GET /api/apm/services/{serviceName}/annotation/search': {
annotations: [],
},
'GET /api/apm/fallback_to_transactions': {
fallbackToTransactions: false,
},
};
/* eslint-enable @typescript-eslint/naming-convention */

View file

@ -5,12 +5,15 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { useUrlParams } from '../../../context/url_params_context/use_url_params';
import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher';
import { APIReturnType } from '../../../services/rest/createCallApmApi';
import { SearchBar } from '../../shared/search_bar';
import { TraceList } from './trace_list';
import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to_transactions_fetcher';
import { AggregatedTransactionsCallout } from '../../shared/aggregated_transactions_callout';
type TracesAPIResponse = APIReturnType<'GET /api/apm/traces'>;
const DEFAULT_RESPONSE: TracesAPIResponse = {
@ -18,6 +21,7 @@ const DEFAULT_RESPONSE: TracesAPIResponse = {
};
export function TraceOverview() {
const { fallbackToTransactions } = useFallbackToTransactionsFetcher();
const {
urlParams: { environment, kuery, start, end },
} = useUrlParams();
@ -44,6 +48,14 @@ export function TraceOverview() {
<>
<SearchBar />
{fallbackToTransactions && (
<EuiFlexGroup>
<EuiFlexItem>
<AggregatedTransactionsCallout />
</EuiFlexItem>
</EuiFlexGroup>
)}
<TraceList
items={data.items}
isLoading={status === FETCH_STATUS.LOADING}

View file

@ -5,13 +5,15 @@
* 2.0.
*/
import { EuiPanel, EuiSpacer } from '@elastic/eui';
import { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Location } from 'history';
import React from 'react';
import { useLocation } from 'react-router-dom';
import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context';
import { IUrlParams } from '../../../context/url_params_context/types';
import { useUrlParams } from '../../../context/url_params_context/use_url_params';
import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to_transactions_fetcher';
import { AggregatedTransactionsCallout } from '../../shared/aggregated_transactions_callout';
import { TransactionCharts } from '../../shared/charts/transaction_charts';
import { fromQuery, toQuery } from '../../shared/Links/url_helpers';
import { TransactionsTable } from '../../shared/transactions_table';
@ -40,6 +42,7 @@ function getRedirectLocation({
}
export function TransactionOverview() {
const { fallbackToTransactions } = useFallbackToTransactionsFetcher();
const location = useLocation();
const { urlParams } = useUrlParams();
const { transactionType, serviceName } = useApmServiceContext();
@ -55,6 +58,16 @@ export function TransactionOverview() {
return (
<>
{fallbackToTransactions && (
<>
<EuiFlexGroup>
<EuiFlexItem>
<AggregatedTransactionsCallout />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
</>
)}
<TransactionCharts />
<EuiSpacer size="s" />
<EuiPanel hasBorder={true}>

View file

@ -0,0 +1,26 @@
/*
* 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 React from 'react';
import { EuiCallOut, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export function AggregatedTransactionsCallout() {
return (
<EuiCallOut
size="s"
title={
<EuiText size="xs">
{i18n.translate('xpack.apm.aggregatedTransactions.callout.title', {
defaultMessage: `This page is using transaction event data as no metrics events were found in the current time range.`,
})}
</EuiText>
}
iconType="iInCircle"
/>
);
}

View file

@ -0,0 +1,28 @@
/*
* 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 { useUrlParams } from '../context/url_params_context/use_url_params';
import { useFetcher } from './use_fetcher';
export function useFallbackToTransactionsFetcher() {
const {
urlParams: { kuery, start, end },
} = useUrlParams();
const { data = { fallbackToTransactions: false } } = useFetcher(
(callApmApi) => {
return callApmApi({
endpoint: 'GET /api/apm/fallback_to_transactions',
params: {
query: { kuery, start, end },
},
});
},
[kuery, start, end]
);
return data;
}

View file

@ -0,0 +1,36 @@
/*
* 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 { getSearchAggregatedTransactions } from '.';
import { SearchAggregatedTransactionSetting } from '../../../../common/aggregated_transactions';
import { Setup, SetupTimeRange } from '../setup_request';
export async function getFallbackToTransactions({
setup: { config, start, end, apmEventClient },
kuery,
}: {
setup: Setup & Partial<SetupTimeRange>;
kuery?: string;
}): Promise<boolean> {
const searchAggregatedTransactions =
config['xpack.apm.searchAggregatedTransactions'];
const neverSearchAggregatedTransactions =
searchAggregatedTransactions === SearchAggregatedTransactionSetting.never;
if (neverSearchAggregatedTransactions) {
return false;
}
const searchesAggregatedTransactions = await getSearchAggregatedTransactions({
config,
start,
end,
apmEventClient,
kuery,
});
return !searchesAggregatedTransactions;
}

View file

@ -12,7 +12,7 @@ import { calculateAuto } from './calculate_auto';
export function getBucketSize({
start,
end,
numBuckets = 100,
numBuckets = 50,
minBucketSize,
}: {
start: number;

View file

@ -62,7 +62,10 @@ interface SetupRequestParams {
type InferSetup<TParams extends SetupRequestParams> = Setup &
(TParams extends { query: { start: number } } ? { start: number } : {}) &
(TParams extends { query: { end: number } } ? { end: number } : {});
(TParams extends { query: { end: number } } ? { end: number } : {}) &
(TParams extends { query: Partial<SetupTimeRange> }
? Partial<SetupTimeRange>
: {});
export async function setupRequest<TParams extends SetupRequestParams>({
context,

View file

@ -0,0 +1,36 @@
/*
* 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 * as t from 'io-ts';
import { getFallbackToTransactions } from '../lib/helpers/aggregated_transactions/get_fallback_to_transactions';
import { setupRequest } from '../lib/helpers/setup_request';
import { createApmServerRoute } from './create_apm_server_route';
import { createApmServerRouteRepository } from './create_apm_server_route_repository';
import { kueryRt, rangeRt } from './default_api_types';
const fallbackToTransactionsRoute = createApmServerRoute({
endpoint: 'GET /api/apm/fallback_to_transactions',
params: t.partial({
query: t.intersection([kueryRt, t.partial(rangeRt.props)]),
}),
options: { tags: ['access:apm'] },
handler: async (resources) => {
const setup = await setupRequest(resources);
const {
params: {
query: { kuery },
},
} = resources;
return {
fallbackToTransactions: await getFallbackToTransactions({ setup, kuery }),
};
},
});
export const fallbackToTransactionsRouteRepository = createApmServerRouteRepository().add(
fallbackToTransactionsRoute
);

View file

@ -21,6 +21,7 @@ import { indexPatternRouteRepository } from './index_pattern';
import { metricsRouteRepository } from './metrics';
import { observabilityOverviewRouteRepository } from './observability_overview';
import { rumRouteRepository } from './rum_client';
import { fallbackToTransactionsRouteRepository } from './fallback_to_transactions';
import { serviceRouteRepository } from './services';
import { serviceMapRouteRepository } from './service_map';
import { serviceNodeRouteRepository } from './service_nodes';
@ -54,7 +55,8 @@ const getTypedGlobalApmServerRouteRepository = () => {
.merge(customLinkRouteRepository)
.merge(sourceMapsRouteRepository)
.merge(apmFleetRouteRepository)
.merge(backendsRouteRepository);
.merge(backendsRouteRepository)
.merge(fallbackToTransactionsRouteRepository);
return repository;
};

View file

@ -368,7 +368,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
.toMatchInline(`
Array [
0,
15,
3,
]
`);
});
@ -397,7 +397,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
.toMatchInline(`
Array [
0,
187.5,
37.5,
]
`);
});