Top erroneous transactions (#134929)

* Add table for top erroneous transactions in error detail page

* Add table for top errors in transaction details page

* Add top errors to a new row on small viewports

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Giorgos Bamparopoulos 2022-07-18 18:57:54 +03:00 committed by GitHub
parent f0325b2f7d
commit 5c56102bf0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1512 additions and 166 deletions

View file

@ -40,6 +40,7 @@ export class Transaction extends BaseSpan {
errors.forEach((error) => {
error.fields['trace.id'] = this.fields['trace.id'];
error.fields['transaction.id'] = this.fields['transaction.id'];
error.fields['transaction.name'] = this.fields['transaction.name'];
error.fields['transaction.type'] = this.fields['transaction.type'];
});

View file

@ -71,6 +71,15 @@ describe('Error details', () => {
cy.get('[data-test-subj="errorDistribution"]').contains('Occurrences');
});
it('shows top erroneous transactions table', () => {
cy.visit(errorDetailsPageHref);
cy.contains('Top 5 affected transactions');
cy.get('[data-test-subj="topErroneousTransactionsTable"]')
.contains('a', 'GET /apple 🍎')
.click();
cy.url().should('include', 'opbeans-java/transactions/view');
});
it('shows a Stacktrace and Metadata tabs', () => {
cy.visit(errorDetailsPageHref);
cy.contains('button', 'Exception stack trace');

View file

@ -0,0 +1,58 @@
/*
* 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 { synthtrace } from '../../../../synthtrace';
import { opbeans } from '../../../fixtures/synthtrace/opbeans';
const start = '2021-10-10T00:00:00.000Z';
const end = '2021-10-10T00:15:00.000Z';
const timeRange = {
rangeFrom: start,
rangeTo: end,
};
describe('Transaction details', () => {
before(async () => {
await synthtrace.index(
opbeans({
from: new Date(start).getTime(),
to: new Date(end).getTime(),
})
);
});
after(async () => {
await synthtrace.clean();
});
beforeEach(() => {
cy.loginAsViewerUser();
cy.visit(
`/app/apm/services/opbeans-java/transactions/view?${new URLSearchParams({
...timeRange,
transactionName: 'GET /api/product',
})}`
);
});
it('shows transaction name and transaction charts', () => {
cy.contains('h2', 'GET /api/product');
cy.get('[data-test-subj="latencyChart"]');
cy.get('[data-test-subj="throughput"]');
cy.get('[data-test-subj="transactionBreakdownChart"]');
cy.get('[data-test-subj="errorRate"]');
});
it('shows top errors table', () => {
cy.contains('Top 5 errors');
cy.get('[data-test-subj="topErrorsForTransactionTable"]')
.contains('a', '[MockError] Foo')
.click();
cy.url().should('include', 'opbeans-java/errors');
});
});

View file

@ -13,6 +13,7 @@ import {
EuiSpacer,
EuiText,
EuiTitle,
EuiHorizontalRule,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
@ -29,6 +30,7 @@ import { useTimeRange } from '../../../hooks/use_time_range';
import type { APIReturnType } from '../../../services/rest/create_call_apm_api';
import { DetailView } from './detail_view';
import { ErrorDistribution } from './distribution';
import { TopErroneousTransactions } from './top_erroneous_transactions';
const Titles = euiStyled.div`
margin-bottom: ${({ theme }) => theme.eui.euiSizeL};
@ -49,6 +51,7 @@ const Message = euiStyled.div`
const Culprit = euiStyled.div`
font-family: ${({ theme }) => theme.eui.euiCodeFontFamily};
margin-bottom: ${({ theme }) => theme.eui.euiSizeS};
`;
type ErrorDistributionAPIResponse =
@ -194,52 +197,69 @@ export function ErrorGroupDetails() {
<EuiSpacer size={'m'} />
<EuiPanel hasBorder={true}>
{showDetails && (
<Titles>
<EuiText>
{logMessage && (
<>
<Label>
{i18n.translate(
'xpack.apm.errorGroupDetails.logMessageLabel',
{
defaultMessage: 'Log message',
}
)}
</Label>
<Message>{logMessage}</Message>
</>
{showDetails && (
<Titles>
<EuiText>
{logMessage && (
<>
<Label>
{i18n.translate(
'xpack.apm.errorGroupDetails.logMessageLabel',
{
defaultMessage: 'Log message',
}
)}
</Label>
<Message>{logMessage}</Message>
</>
)}
<Label>
{i18n.translate(
'xpack.apm.errorGroupDetails.exceptionMessageLabel',
{
defaultMessage: 'Exception message',
}
)}
<Label>
{i18n.translate(
'xpack.apm.errorGroupDetails.exceptionMessageLabel',
{
defaultMessage: 'Exception message',
}
)}
</Label>
<Message>{excMessage || NOT_AVAILABLE_LABEL}</Message>
<Label>
{i18n.translate('xpack.apm.errorGroupDetails.culpritLabel', {
defaultMessage: 'Culprit',
})}
</Label>
<Culprit>{culprit || NOT_AVAILABLE_LABEL}</Culprit>
</EuiText>
</Titles>
)}
<ErrorDistribution
fetchStatus={status}
distribution={showDetails ? errorDistributionData : emptyState}
title={i18n.translate(
'xpack.apm.errorGroupDetails.occurrencesChartLabel',
{
defaultMessage: 'Occurrences',
}
)}
/>
</EuiPanel>
</Label>
<Message>{excMessage || NOT_AVAILABLE_LABEL}</Message>
<Label>
{i18n.translate('xpack.apm.errorGroupDetails.culpritLabel', {
defaultMessage: 'Culprit',
})}
</Label>
<Culprit>{culprit || NOT_AVAILABLE_LABEL}</Culprit>
<Label>
{i18n.translate('xpack.apm.errorGroupDetails.occurrencesLabel', {
defaultMessage: 'Occurrences',
})}
</Label>
{errorGroupData.occurrencesCount}
</EuiText>
<EuiHorizontalRule />
</Titles>
)}
<EuiFlexGroup>
<EuiFlexItem grow={3}>
<EuiPanel hasBorder={true}>
<ErrorDistribution
fetchStatus={status}
distribution={showDetails ? errorDistributionData : emptyState}
title={i18n.translate(
'xpack.apm.errorGroupDetails.occurrencesChartLabel',
{
defaultMessage: 'Occurrences',
}
)}
/>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={2}>
<EuiPanel hasBorder={true}>
<TopErroneousTransactions serviceName={serviceName} />
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
{showDetails && (
<DetailView

View file

@ -0,0 +1,208 @@
/*
* 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 { i18n } from '@kbn/i18n';
import {
EuiBasicTable,
EuiBasicTableColumn,
EuiTitle,
RIGHT_ALIGNMENT,
EuiSpacer,
} from '@elastic/eui';
import { ValuesType } from 'utility-types';
import type { APIReturnType } from '../../../../services/rest/create_call_apm_api';
import { SparkPlot } from '../../../shared/charts/spark_plot';
import {
ChartType,
getTimeSeriesColor,
} from '../../../shared/charts/helper/get_timeseries_color';
import { isTimeComparison } from '../../../shared/time_comparison/get_comparison_options';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { TransactionDetailLink } from '../../../shared/links/apm/transaction_detail_link';
import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip';
import { useFetcher, FETCH_STATUS } from '../../../../hooks/use_fetcher';
import { useTimeRange } from '../../../../hooks/use_time_range';
import { asInteger } from '../../../../../common/utils/formatters';
type ErroneousTransactions =
APIReturnType<'GET /internal/apm/services/{serviceName}/errors/{groupId}/top_erroneous_transactions'>;
interface Props {
serviceName: string;
}
const INITIAL_STATE: ErroneousTransactions = {
topErroneousTransactions: [],
};
export function TopErroneousTransactions({ serviceName }: Props) {
const {
query,
path: { groupId },
} = useApmParams('/services/{serviceName}/errors/{groupId}');
const { rangeFrom, rangeTo, environment, kuery, offset, comparisonEnabled } =
query;
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const { data = INITIAL_STATE, status } = useFetcher(
(callApmApi) => {
if (start && end) {
return callApmApi(
'GET /internal/apm/services/{serviceName}/errors/{groupId}/top_erroneous_transactions',
{
params: {
path: {
serviceName,
groupId,
},
query: {
environment,
kuery,
start,
end,
numBuckets: 20,
offset:
comparisonEnabled && isTimeComparison(offset)
? offset
: undefined,
},
},
}
);
}
},
[
environment,
kuery,
serviceName,
start,
end,
groupId,
comparisonEnabled,
offset,
]
);
const loading =
status === FETCH_STATUS.LOADING || status === FETCH_STATUS.NOT_INITIATED;
const columns: Array<
EuiBasicTableColumn<
ValuesType<ErroneousTransactions['topErroneousTransactions']>
>
> = [
{
field: 'transactionName',
width: '60%',
name: i18n.translate(
'xpack.apm.errorGroupTopTransactions.column.transactionName',
{
defaultMessage: 'Transaction name',
}
),
render: (_, { transactionName, transactionType }) => {
return (
<TruncateWithTooltip
text={transactionName}
content={
<TransactionDetailLink
serviceName={serviceName}
transactionName={transactionName}
transactionType={transactionType ?? ''}
comparisonEnabled={comparisonEnabled}
offset={offset}
>
{transactionName}
</TransactionDetailLink>
}
/>
);
},
},
{
field: 'occurrences',
name: i18n.translate(
'xpack.apm.errorGroupTopTransactions.column.occurrences',
{
defaultMessage: 'Error occurrences',
}
),
align: RIGHT_ALIGNMENT,
dataType: 'number',
render: (
_,
{ occurrences, currentPeriodTimeseries, previousPeriodTimeseries }
) => {
const { currentPeriodColor, previousPeriodColor } = getTimeSeriesColor(
ChartType.FAILED_TRANSACTION_RATE
);
return (
<SparkPlot
isLoading={loading}
valueLabel={i18n.translate(
'xpack.apm.errorGroupTopTransactions.column.occurrences.valueLabel',
{
defaultMessage: `{occurrences} occ.`,
values: {
occurrences: asInteger(occurrences),
},
}
)}
series={currentPeriodTimeseries}
comparisonSeries={
comparisonEnabled && isTimeComparison(offset)
? previousPeriodTimeseries
: undefined
}
color={currentPeriodColor}
comparisonSeriesColor={previousPeriodColor}
/>
);
},
},
];
return (
<>
<EuiTitle size="xs">
<h3>
{i18n.translate('xpack.apm.errorGroupTopTransactions.title', {
defaultMessage: 'Top 5 affected transactions',
})}
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiBasicTable
items={data.topErroneousTransactions}
columns={columns}
loading={loading}
data-test-subj="topErroneousTransactionsTable"
error={
status === FETCH_STATUS.FAILURE
? i18n.translate(
'xpack.apm.errorGroupTopTransactions.errorMessage',
{ defaultMessage: 'Failed to fetch' }
)
: ''
}
noItemsMessage={
loading
? i18n.translate('xpack.apm.errorGroupTopTransactions.loading', {
defaultMessage: 'Loading...',
})
: i18n.translate('xpack.apm.errorGroupTopTransactions.noResults', {
defaultMessage: 'No errors found associated with transactions',
})
}
/>
</>
);
}

View file

@ -66,6 +66,7 @@ export function ServiceDependenciesBreakdownChart({
annotations={[]}
timeseries={timeseries}
yAxisType="duration"
id="serviceDependenciesBreakdownChart"
/>
);
}

View file

@ -20,7 +20,7 @@ import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
import { APIReturnType } from '../../../../services/rest/create_call_apm_api';
import { ErrorOverviewLink } from '../../../shared/links/apm/error_overview_link';
import { OverviewTableContainer } from '../../../shared/overview_table_container';
import { getColumns } from './get_columns';
import { getColumns } from '../../../shared/errors_table/get_columns';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { useTimeRange } from '../../../../hooks/use_time_range';

View file

@ -0,0 +1,204 @@
/*
* 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 {
EuiBasicTable,
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import uuid from 'uuid';
import { isTimeComparison } from '../../../shared/time_comparison/get_comparison_options';
import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
import { APIReturnType } from '../../../../services/rest/create_call_apm_api';
import { getColumns } from '../../../shared/errors_table/get_columns';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { useTimeRange } from '../../../../hooks/use_time_range';
type ErrorGroupMainStatisticsByTransactionName =
APIReturnType<'GET /internal/apm/services/{serviceName}/errors/groups/main_statistics_by_transaction_name'>;
type ErrorGroupDetailedStatistics =
APIReturnType<'POST /internal/apm/services/{serviceName}/errors/groups/detailed_statistics'>;
const INITIAL_STATE_MAIN_STATISTICS: {
items: ErrorGroupMainStatisticsByTransactionName['errorGroups'];
requestId?: string;
} = {
items: [],
requestId: undefined,
};
const INITIAL_STATE_DETAILED_STATISTICS: ErrorGroupDetailedStatistics = {
currentPeriod: {},
previousPeriod: {},
};
export function TopErrors() {
const {
query,
path: { serviceName },
} = useApmParams('/services/{serviceName}/transactions/view');
const {
environment,
kuery,
rangeFrom,
rangeTo,
offset,
comparisonEnabled,
transactionName,
transactionType,
} = query;
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const { data = INITIAL_STATE_MAIN_STATISTICS, status } = useFetcher(
(callApmApi) => {
if (start && end && transactionType) {
return callApmApi(
'GET /internal/apm/services/{serviceName}/errors/groups/main_statistics_by_transaction_name',
{
params: {
path: { serviceName },
query: {
environment,
kuery,
start,
end,
transactionName,
transactionType,
maxNumberOfErrorGroups: 5,
},
},
}
).then((response) => {
return {
// Everytime the main statistics is refetched, updates the requestId making the comparison API to be refetched.
requestId: uuid(),
items: response.errorGroups,
};
});
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
environment,
kuery,
start,
end,
serviceName,
transactionName,
transactionType,
// not used, but needed to trigger an update when offset is changed either manually by user or when time range is changed
offset,
// not used, but needed to trigger an update when comparison feature is disabled/enabled by user
comparisonEnabled,
]
);
const { requestId, items } = data;
const {
data: errorGroupDetailedStatistics = INITIAL_STATE_DETAILED_STATISTICS,
status: errorGroupDetailedStatisticsStatus,
} = useFetcher(
(callApmApi) => {
if (requestId && items.length && start && end) {
return callApmApi(
'POST /internal/apm/services/{serviceName}/errors/groups/detailed_statistics',
{
params: {
path: { serviceName },
query: {
environment,
kuery,
start,
end,
numBuckets: 20,
offset:
comparisonEnabled && isTimeComparison(offset)
? offset
: undefined,
},
body: {
groupIds: JSON.stringify(
items.map(({ groupId: groupId }) => groupId).sort()
),
},
},
}
);
}
},
// only fetches agg results when requestId changes
// eslint-disable-next-line react-hooks/exhaustive-deps
[requestId],
{ preservePreviousData: false }
);
const errorGroupDetailedStatisticsLoading =
errorGroupDetailedStatisticsStatus === FETCH_STATUS.LOADING;
const columns = getColumns({
serviceName,
errorGroupDetailedStatisticsLoading,
errorGroupDetailedStatistics,
comparisonEnabled,
query,
showErrorType: false,
});
return (
<EuiFlexGroup
direction="column"
gutterSize="s"
data-test-subj="topErrorsForTransactionTable"
>
<EuiFlexItem>
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.apm.transactionDetails.topErrors.title', {
defaultMessage: 'Top 5 errors',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiBasicTable
error={
status === FETCH_STATUS.FAILURE
? i18n.translate(
'xpack.apm.transactionDetails.topErrors.errorMessage',
{ defaultMessage: 'Failed to fetch errors' }
)
: ''
}
noItemsMessage={
status === FETCH_STATUS.LOADING
? i18n.translate(
'xpack.apm.transactionDetails.topErrors.loading',
{ defaultMessage: 'Loading...' }
)
: i18n.translate(
'xpack.apm.transactionDetails.topErrors.noResults',
{
defaultMessage:
'No errors found for this transaction group',
}
)
}
columns={columns}
items={items}
loading={status === FETCH_STATUS.LOADING}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -54,6 +54,7 @@ interface Props {
annotations: Annotation[];
timeseries?: Array<TimeSeries<Coordinate>>;
yAxisType: 'duration' | 'percentage';
id?: string;
}
const asPercentBound = (y: number | null) => asPercent(y, 1);
@ -65,6 +66,7 @@ export function BreakdownChart({
annotations,
timeseries,
yAxisType,
id,
}: Props) {
const history = useHistory();
const chartTheme = useChartTheme();
@ -94,7 +96,12 @@ export function BreakdownChart({
const timeZone = getTimeZone(core.uiSettings);
return (
<ChartContainer height={height} hasData={!isEmpty} status={fetchStatus}>
<ChartContainer
height={height}
hasData={!isEmpty}
status={fetchStatus}
id={id}
>
<Chart ref={chartRef}>
<Settings
tooltip={{ stickTo: 'top', showNullValues: true }}

View file

@ -68,6 +68,7 @@ export function TransactionBreakdownChart({
showAnnotations={showAnnotations}
timeseries={timeseries}
yAxisType="percentage"
id="transactionBreakdownChart"
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -5,7 +5,13 @@
* 2.0.
*/
import { EuiFlexGrid, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui';
import {
EuiFlexGrid,
EuiFlexItem,
EuiPanel,
EuiSpacer,
EuiFlexGroup,
} from '@elastic/eui';
import React from 'react';
import { AnnotationsContextProvider } from '../../../../context/annotations/annotations_context';
import { ChartPointerEventContextProvider } from '../../../../context/chart_pointer_event/chart_pointer_event_context';
@ -14,6 +20,8 @@ import { LatencyChart } from '../latency_chart';
import { TransactionBreakdownChart } from '../transaction_breakdown_chart';
import { TransactionColdstartRateChart } from '../transaction_coldstart_rate_chart';
import { FailedTransactionRateChart } from '../failed_transaction_rate_chart';
import { TopErrors } from '../../../app/transaction_details/top_errors';
import { useBreakpoints } from '../../../../hooks/use_breakpoints';
export function TransactionCharts({
kuery,
@ -34,6 +42,49 @@ export function TransactionCharts({
comparisonEnabled?: boolean;
offset?: string;
}) {
// The default EuiFlexGroup breaks at 768, but we want to break at 1200
const { isLarge } = useBreakpoints();
const rowDirection = isLarge ? 'column' : 'row';
const latencyChart = (
<EuiFlexItem data-cy={`transaction-duration-charts`}>
<EuiPanel hasBorder={true}>
<LatencyChart kuery={kuery} />
</EuiPanel>
</EuiFlexItem>
);
const serviceOverviewThroughputChart = (
<EuiFlexItem style={{ flexShrink: 1 }}>
<ServiceOverviewThroughputChart
kuery={kuery}
transactionName={transactionName}
/>
</EuiFlexItem>
);
const coldStartRateOrBreakdownChart = isServerlessContext ? (
<EuiFlexItem>
<TransactionColdstartRateChart
kuery={kuery}
transactionName={transactionName}
environment={environment}
comparisonEnabled={comparisonEnabled}
offset={offset}
/>
</EuiFlexItem>
) : (
<EuiFlexItem>
<TransactionBreakdownChart kuery={kuery} environment={environment} />
</EuiFlexItem>
);
const failedTransactionRateChart = (
<EuiFlexItem grow={1}>
<FailedTransactionRateChart kuery={kuery} />
</EuiFlexItem>
);
return (
<>
<AnnotationsContextProvider
@ -42,46 +93,40 @@ export function TransactionCharts({
end={end}
>
<ChartPointerEventContextProvider>
<EuiFlexGrid columns={2} gutterSize="s">
<EuiFlexItem data-cy={`transaction-duration-charts`}>
<EuiPanel hasBorder={true}>
<LatencyChart kuery={kuery} />
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem style={{ flexShrink: 1 }}>
<ServiceOverviewThroughputChart
kuery={kuery}
transactionName={transactionName}
/>
</EuiFlexItem>
</EuiFlexGrid>
<EuiSpacer size="s" />
<EuiFlexGrid columns={2} gutterSize="s">
<EuiFlexItem>
<FailedTransactionRateChart kuery={kuery} />
</EuiFlexItem>
{isServerlessContext ? (
<EuiFlexItem>
<TransactionColdstartRateChart
kuery={kuery}
transactionName={transactionName}
environment={environment}
comparisonEnabled={comparisonEnabled}
offset={offset}
/>
</EuiFlexItem>
) : (
<EuiFlexItem>
<TransactionBreakdownChart
kuery={kuery}
environment={environment}
/>
</EuiFlexItem>
)}
</EuiFlexGrid>
{transactionName ? (
<>
<EuiFlexGrid columns={3} gutterSize="s">
{latencyChart}
{serviceOverviewThroughputChart}
{coldStartRateOrBreakdownChart}
</EuiFlexGrid>
<EuiSpacer size="l" />
<EuiFlexGroup
direction={rowDirection}
gutterSize="s"
responsive={false}
>
{failedTransactionRateChart}
<EuiFlexItem grow={2}>
<EuiPanel hasBorder={true}>
<TopErrors />
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</>
) : (
<>
<EuiFlexGrid columns={2} gutterSize="s">
{latencyChart}
{serviceOverviewThroughputChart}
</EuiFlexGrid>
<EuiSpacer size="s" />
<EuiFlexGrid columns={2} gutterSize="s">
{failedTransactionRateChart}
{coldStartRateOrBreakdownChart}
</EuiFlexGrid>
</>
)}
</ChartPointerEventContextProvider>
</AnnotationsContextProvider>
</>

View file

@ -5,32 +5,38 @@
* 2.0.
*/
import { EuiBasicTableColumn, RIGHT_ALIGNMENT } from '@elastic/eui';
import {
EuiBasicTableColumn,
RIGHT_ALIGNMENT,
CENTER_ALIGNMENT,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { TypeOf } from '@kbn/typed-react-router-config';
import React from 'react';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { isTimeComparison } from '../../../shared/time_comparison/get_comparison_options';
import { asInteger } from '../../../../../common/utils/formatters';
import { APIReturnType } from '../../../../services/rest/create_call_apm_api';
import { truncate } from '../../../../utils/style';
import { SparkPlot } from '../../../shared/charts/spark_plot';
import { ErrorDetailLink } from '../../../shared/links/apm/error_detail_link';
import { ErrorOverviewLink } from '../../../shared/links/apm/error_overview_link';
import { TimestampTooltip } from '../../../shared/timestamp_tooltip';
import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip';
import { isTimeComparison } from '../time_comparison/get_comparison_options';
import { asInteger } from '../../../../common/utils/formatters';
import { APIReturnType } from '../../../services/rest/create_call_apm_api';
import { truncate } from '../../../utils/style';
import { SparkPlot } from '../charts/spark_plot';
import { ErrorDetailLink } from '../links/apm/error_detail_link';
import { ErrorOverviewLink } from '../links/apm/error_overview_link';
import { TimestampTooltip } from '../timestamp_tooltip';
import { TruncateWithTooltip } from '../truncate_with_tooltip';
import {
ChartType,
getTimeSeriesColor,
} from '../../../shared/charts/helper/get_timeseries_color';
import { ApmRoutes } from '../../../routing/apm_route_config';
} from '../charts/helper/get_timeseries_color';
import { ApmRoutes } from '../../routing/apm_route_config';
const ErrorLink = euiStyled(ErrorOverviewLink)`
${truncate('100%')};
`;
type ErrorGroupMainStatistics =
APIReturnType<'GET /internal/apm/services/{serviceName}/errors/groups/main_statistics'>;
type ErrorGroupMainStatistics = APIReturnType<
| 'GET /internal/apm/services/{serviceName}/errors/groups/main_statistics'
| 'GET /internal/apm/services/{serviceName}/errors/groups/main_statistics_by_transaction_name'
>;
type ErrorGroupDetailedStatistics =
APIReturnType<'POST /internal/apm/services/{serviceName}/errors/groups/detailed_statistics'>;
@ -40,41 +46,50 @@ export function getColumns({
errorGroupDetailedStatistics,
comparisonEnabled,
query,
showErrorType = true,
}: {
serviceName: string;
errorGroupDetailedStatisticsLoading: boolean;
errorGroupDetailedStatistics: ErrorGroupDetailedStatistics;
comparisonEnabled?: boolean;
query: TypeOf<ApmRoutes, '/services/{serviceName}/errors'>['query'];
showErrorType?: boolean;
}): Array<EuiBasicTableColumn<ErrorGroupMainStatistics['errorGroups'][0]>> {
const { offset } = query;
return [
{
name: i18n.translate('xpack.apm.errorsTable.typeColumnLabel', {
defaultMessage: 'Type',
}),
field: 'type',
sortable: false,
render: (_, { type }) => {
return (
<ErrorLink
title={type}
serviceName={serviceName}
query={
{
...query,
kuery: `error.exception.type:"${type}"`,
} as TypeOf<ApmRoutes, '/services/{serviceName}/errors'>['query']
}
>
{type}
</ErrorLink>
);
},
},
...(showErrorType
? [
{
name: i18n.translate('xpack.apm.errorsTable.typeColumnLabel', {
defaultMessage: 'Type',
}),
field: 'type',
sortable: false,
render: (_, { type }) => {
return (
<ErrorLink
title={type}
serviceName={serviceName}
query={
{
...query,
kuery: `error.exception.type:"${type}"`,
} as TypeOf<
ApmRoutes,
'/services/{serviceName}/errors'
>['query']
}
>
{type}
</ErrorLink>
);
},
} as EuiBasicTableColumn<ErrorGroupMainStatistics['errorGroups'][0]>,
]
: []),
{
field: 'name',
name: i18n.translate('xpack.apm.serviceOverview.errorsTableColumnName', {
name: i18n.translate('xpack.apm.errorsTable.columnName', {
defaultMessage: 'Name',
}),
render: (_, { name, groupId: errorGroupId }) => {
@ -95,13 +110,10 @@ export function getColumns({
},
{
field: 'lastSeen',
name: i18n.translate(
'xpack.apm.serviceOverview.errorsTableColumnLastSeen',
{
defaultMessage: 'Last seen',
}
),
align: RIGHT_ALIGNMENT,
name: i18n.translate('xpack.apm.errorsTable.columnLastSeen', {
defaultMessage: 'Last seen',
}),
align: showErrorType ? RIGHT_ALIGNMENT : CENTER_ALIGNMENT,
render: (_, { lastSeen }) => {
return (
<span style={{ overflow: 'hidden', whiteSpace: 'nowrap' }}>
@ -112,12 +124,9 @@ export function getColumns({
},
{
field: 'occurrences',
name: i18n.translate(
'xpack.apm.serviceOverview.errorsTableColumnOccurrences',
{
defaultMessage: 'Occurrences',
}
),
name: i18n.translate('xpack.apm.errorsTable.columnOccurrences', {
defaultMessage: 'Occurrences',
}),
align: RIGHT_ALIGNMENT,
render: (_, { occurrences, groupId: errorGroupId }) => {
const currentPeriodTimeseries =
@ -135,15 +144,12 @@ export function getColumns({
color={currentPeriodColor}
isLoading={errorGroupDetailedStatisticsLoading}
series={currentPeriodTimeseries}
valueLabel={i18n.translate(
'xpack.apm.serviceOveriew.errorsTableOccurrences',
{
defaultMessage: `{occurrences} occ.`,
values: {
occurrences: asInteger(occurrences),
},
}
)}
valueLabel={i18n.translate('xpack.apm.errorsTable.occurrences', {
defaultMessage: `{occurrences} occ.`,
values: {
occurrences: asInteger(occurrences),
},
})}
comparisonSeries={
comparisonEnabled && isTimeComparison(offset)
? previousPeriodTimeseries

View file

@ -0,0 +1,196 @@
/*
* 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.
*/
/*
* 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 {
rangeQuery,
kqlQuery,
termQuery,
} from '@kbn/observability-plugin/server';
import { keyBy } from 'lodash';
import {
ERROR_GROUP_ID,
SERVICE_NAME,
TRANSACTION_NAME,
TRANSACTION_TYPE,
} from '../../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../../common/processor_event';
import { environmentQuery } from '../../../../common/utils/environment_query';
import { Setup } from '../../../lib/helpers/setup_request';
import { getBucketSize } from '../../../lib/helpers/get_bucket_size';
import { getOffsetInMs } from '../../../../common/utils/get_offset_in_ms';
async function getTopErroneousTransactions({
environment,
kuery,
serviceName,
groupId,
setup,
start,
end,
numBuckets,
offset,
}: {
environment: string;
kuery: string;
serviceName: string;
groupId: string;
setup: Setup;
start: number;
end: number;
numBuckets: number;
offset?: string;
}) {
const { apmEventClient } = setup;
const { startWithOffset, endWithOffset, offsetInMs } = getOffsetInMs({
start,
end,
offset,
});
const { intervalString } = getBucketSize({
start: startWithOffset,
end: endWithOffset,
numBuckets,
});
const res = await apmEventClient.search('get_top_erroneous_transactions', {
apm: {
events: [ProcessorEvent.error],
},
body: {
size: 0,
query: {
bool: {
filter: [
...termQuery(SERVICE_NAME, serviceName),
...termQuery(ERROR_GROUP_ID, groupId),
...rangeQuery(startWithOffset, endWithOffset),
...environmentQuery(environment),
...kqlQuery(kuery),
],
},
},
aggs: {
top_five_transactions: {
terms: {
field: TRANSACTION_NAME,
size: 5,
},
aggs: {
sample: {
top_hits: {
size: 1,
_source: [TRANSACTION_TYPE],
},
},
timeseries: {
date_histogram: {
field: '@timestamp',
fixed_interval: intervalString,
extended_bounds: {
min: startWithOffset,
max: endWithOffset,
},
},
},
},
},
},
},
});
return (
res.aggregations?.top_five_transactions.buckets.map(
({ key, doc_count: docCount, sample, timeseries }) => ({
transactionName: key as string,
transactionType: sample.hits.hits[0]._source.transaction?.type,
occurrences: docCount,
timeseries: timeseries.buckets.map((timeseriesBucket) => {
return {
x: timeseriesBucket.key + offsetInMs,
y: timeseriesBucket.doc_count,
};
}),
})
) ?? []
);
}
export async function getTopErroneousTransactionsPeriods({
kuery,
serviceName,
setup,
numBuckets,
groupId,
environment,
start,
end,
offset,
}: {
kuery: string;
serviceName: string;
setup: Setup;
numBuckets: number;
groupId: string;
environment: string;
start: number;
end: number;
offset?: string;
}) {
const [currentPeriod, previousPeriod] = await Promise.all([
getTopErroneousTransactions({
environment,
kuery,
serviceName,
setup,
numBuckets,
groupId,
start,
end,
}),
offset
? getTopErroneousTransactions({
environment,
kuery,
serviceName,
setup,
numBuckets,
groupId,
start,
end,
offset,
})
: [],
]);
const previousPeriodByTransactionName = keyBy(
previousPeriod,
'transactionName'
);
return {
topErroneousTransactions: currentPeriod.map(
({ transactionName, timeseries: currentPeriodTimeseries, ...rest }) => {
return {
...rest,
transactionName,
currentPeriodTimeseries,
previousPeriodTimeseries:
previousPeriodByTransactionName[transactionName]?.timeseries ?? [],
};
}
),
};
}

View file

@ -5,7 +5,12 @@
* 2.0.
*/
import { keyBy } from 'lodash';
import { rangeQuery, kqlQuery } from '@kbn/observability-plugin/server';
import {
rangeQuery,
kqlQuery,
termQuery,
termsQuery,
} from '@kbn/observability-plugin/server';
import { offsetPreviousPeriodCoordinates } from '../../../../common/utils/offset_previous_period_coordinate';
import { Coordinate } from '../../../../typings/timeseries';
import {
@ -64,8 +69,8 @@ export async function getErrorGroupDetailedStatistics({
query: {
bool: {
filter: [
{ terms: { [ERROR_GROUP_ID]: groupIds } },
{ term: { [SERVICE_NAME]: serviceName } },
...termsQuery(ERROR_GROUP_ID, ...groupIds),
...termQuery(SERVICE_NAME, serviceName),
...rangeQuery(startWithOffset, endWithOffset),
...environmentQuery(environment),
...kqlQuery(kuery),

View file

@ -6,7 +6,11 @@
*/
import { AggregationsTermsAggregationOrder } from '@elastic/elasticsearch/lib/api/types';
import { kqlQuery, rangeQuery } from '@kbn/observability-plugin/server';
import {
kqlQuery,
rangeQuery,
termQuery,
} from '@kbn/observability-plugin/server';
import {
ERROR_CULPRIT,
ERROR_EXC_HANDLED,
@ -15,6 +19,8 @@ import {
ERROR_GROUP_ID,
ERROR_LOG_MESSAGE,
SERVICE_NAME,
TRANSACTION_NAME,
TRANSACTION_TYPE,
} from '../../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../../common/processor_event';
import { environmentQuery } from '../../../../common/utils/environment_query';
@ -30,6 +36,9 @@ export async function getErrorGroupMainStatistics({
sortDirection = 'desc',
start,
end,
maxNumberOfErrorGroups = 500,
transactionName,
transactionType,
}: {
kuery: string;
serviceName: string;
@ -39,6 +48,9 @@ export async function getErrorGroupMainStatistics({
sortDirection?: 'asc' | 'desc';
start: number;
end: number;
maxNumberOfErrorGroups?: number;
transactionName?: string;
transactionType?: string;
}) {
const { apmEventClient } = setup;
@ -62,7 +74,9 @@ export async function getErrorGroupMainStatistics({
query: {
bool: {
filter: [
{ term: { [SERVICE_NAME]: serviceName } },
...termQuery(SERVICE_NAME, serviceName),
...termQuery(TRANSACTION_NAME, transactionName),
...termQuery(TRANSACTION_TYPE, transactionType),
...rangeQuery(start, end),
...environmentQuery(environment),
...kqlQuery(kuery),
@ -73,7 +87,7 @@ export async function getErrorGroupMainStatistics({
error_groups: {
terms: {
field: ERROR_GROUP_ID,
size: 500,
size: maxNumberOfErrorGroups,
order,
},
aggs: {

View file

@ -15,6 +15,7 @@ import { getErrorGroupMainStatistics } from './get_error_groups/get_error_group_
import { getErrorGroupPeriods } from './get_error_groups/get_error_group_detailed_statistics';
import { getErrorGroupSample } from './get_error_groups/get_error_group_sample';
import { offsetRt } from '../../../common/comparison_rt';
import { getTopErroneousTransactionsPeriods } from './erroneous_transactions/get_top_erroneous_transactions';
const errorsMainStatisticsRoute = createApmServerRoute({
endpoint:
@ -68,6 +69,67 @@ const errorsMainStatisticsRoute = createApmServerRoute({
},
});
const errorsMainStatisticsByTransactionNameRoute = createApmServerRoute({
endpoint:
'GET /internal/apm/services/{serviceName}/errors/groups/main_statistics_by_transaction_name',
params: t.type({
path: t.type({
serviceName: t.string,
}),
query: t.intersection([
t.type({
transactionType: t.string,
transactionName: t.string,
maxNumberOfErrorGroups: toNumberRt,
}),
environmentRt,
kueryRt,
rangeRt,
]),
}),
options: { tags: ['access:apm'] },
handler: async (
resources
): Promise<{
errorGroups: Array<{
groupId: string;
name: string;
lastSeen: number;
occurrences: number;
culprit: string | undefined;
handled: boolean | undefined;
type: string | undefined;
}>;
}> => {
const { params } = resources;
const setup = await setupRequest(resources);
const { serviceName } = params.path;
const {
environment,
kuery,
start,
end,
transactionName,
transactionType,
maxNumberOfErrorGroups,
} = params.query;
const errorGroups = await getErrorGroupMainStatistics({
environment,
kuery,
serviceName,
setup,
start,
end,
maxNumberOfErrorGroups,
transactionName,
transactionType,
});
return { errorGroups };
},
});
const errorsDetailedStatisticsRoute = createApmServerRoute({
endpoint:
'POST /internal/apm/services/{serviceName}/errors/groups/detailed_statistics',
@ -205,9 +267,63 @@ const errorDistributionRoute = createApmServerRoute({
},
});
const topErroneousTransactionsRoute = createApmServerRoute({
endpoint:
'GET /internal/apm/services/{serviceName}/errors/{groupId}/top_erroneous_transactions',
params: t.type({
path: t.type({
serviceName: t.string,
groupId: t.string,
}),
query: t.intersection([
environmentRt,
kueryRt,
rangeRt,
offsetRt,
t.type({
numBuckets: toNumberRt,
}),
]),
}),
options: { tags: ['access:apm'] },
handler: async (
resources
): Promise<{
topErroneousTransactions: Array<{
transactionName: string;
currentPeriodTimeseries: Array<{ x: number; y: number }>;
previousPeriodTimeseries: Array<{ x: number; y: number }>;
transactionType: string | undefined;
occurrences: number;
}>;
}> => {
const { params } = resources;
const setup = await setupRequest(resources);
const {
path: { serviceName, groupId },
query: { environment, kuery, numBuckets, start, end, offset },
} = params;
return await getTopErroneousTransactionsPeriods({
environment,
groupId,
kuery,
serviceName,
setup,
start,
end,
numBuckets,
offset,
});
},
});
export const errorsRouteRepository = {
...errorsMainStatisticsRoute,
...errorsMainStatisticsByTransactionNameRoute,
...errorsDetailedStatisticsRoute,
...errorGroupsRoute,
...errorDistributionRoute,
...topErroneousTransactionsRoute,
};

View file

@ -8066,7 +8066,6 @@
"xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningText": "Nous n'avons pas pu déterminer à quelles JVM ces indicateurs correspondent. Cela provient probablement du fait que vous exécutez une version du serveur APM antérieure à 7.5. La mise à niveau du serveur APM vers la version 7.5 ou supérieure devrait résoudre le problème. Pour plus d'informations sur la mise à niveau, consultez {link}. Vous pouvez également utiliser la barre de recherche de Kibana pour filtrer par nom d'hôte, par ID de conteneur ou en fonction d'autres champs.",
"xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningTitle": "Impossible d'identifier les JVM",
"xpack.apm.serviceNodeNameMissing": "(vide)",
"xpack.apm.serviceOveriew.errorsTableOccurrences": "{occurrences} occ.",
"xpack.apm.serviceOverview.coldstartHelp": "Le taux de démarrage à froid indique le pourcentage de demandes qui déclenchent un démarrage à froid d'une fonction sans serveur.",
"xpack.apm.serviceOverview.dependenciesTableColumn": "Dépendance",
"xpack.apm.serviceOverview.dependenciesTableTabLink": "Afficher les dépendances",
@ -8075,9 +8074,6 @@
"xpack.apm.serviceOverview.errorsTable.errorMessage": "Impossible de récupérer",
"xpack.apm.serviceOverview.errorsTable.loading": "Chargement...",
"xpack.apm.serviceOverview.errorsTable.noResults": "Aucune erreur trouvée",
"xpack.apm.serviceOverview.errorsTableColumnLastSeen": "Vu en dernier",
"xpack.apm.serviceOverview.errorsTableColumnName": "Nom",
"xpack.apm.serviceOverview.errorsTableColumnOccurrences": "Occurrences",
"xpack.apm.serviceOverview.errorsTableLinkText": "Afficher les erreurs",
"xpack.apm.serviceOverview.errorsTableTitle": "Erreurs",
"xpack.apm.serviceOverview.instancesTable.actionMenus.container.subtitle": "Affichez les logs et les indicateurs de ce conteneur pour plus de détails.",

View file

@ -8058,7 +8058,6 @@
"xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningText": "これらのメトリックが所属する JVM を特定できませんでした。7.5 よりも古い APM Server を実行していることが原因である可能性が高いです。この問題は APM Server 7.5 以降にアップグレードすることで解決されます。アップグレードに関する詳細は、{link} をご覧ください。代わりに Kibana クエリバーを使ってホスト名、コンテナー ID、またはその他フィールドでフィルタリングすることもできます。",
"xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningTitle": "JVM を特定できませんでした",
"xpack.apm.serviceNodeNameMissing": "(空)",
"xpack.apm.serviceOveriew.errorsTableOccurrences": "{occurrences}件",
"xpack.apm.serviceOverview.coldstartHelp": "コールドスタート率は、サーバーレス機能のコールドスタートをトリガーするリクエストの割合を示します。",
"xpack.apm.serviceOverview.dependenciesTableColumn": "依存関係",
"xpack.apm.serviceOverview.dependenciesTableTabLink": "依存関係を表示",
@ -8067,9 +8066,6 @@
"xpack.apm.serviceOverview.errorsTable.errorMessage": "取得できませんでした",
"xpack.apm.serviceOverview.errorsTable.loading": "読み込み中...",
"xpack.apm.serviceOverview.errorsTable.noResults": "エラーが見つかりません",
"xpack.apm.serviceOverview.errorsTableColumnLastSeen": "前回の認識",
"xpack.apm.serviceOverview.errorsTableColumnName": "名前",
"xpack.apm.serviceOverview.errorsTableColumnOccurrences": "オカレンス",
"xpack.apm.serviceOverview.errorsTableLinkText": "エラーを表示",
"xpack.apm.serviceOverview.errorsTableTitle": "エラー",
"xpack.apm.serviceOverview.instancesTable.actionMenus.container.subtitle": "このコンテナーのログとインデックスを表示し、さらに詳細を確認できます。",

View file

@ -8072,7 +8072,6 @@
"xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningText": "无法识别这些指标属于哪些 JVM。这可能因为运行的 APM Server 版本低于 7.5。如果升级到 APM Server 7.5 或更高版本,应可解决此问题。有关升级的详细信息,请参阅 {link}。或者,也可以使用 Kibana 查询栏按主机名、容器 ID 或其他字段筛选。",
"xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningTitle": "找不到 JVM",
"xpack.apm.serviceNodeNameMissing": "(空)",
"xpack.apm.serviceOveriew.errorsTableOccurrences": "{occurrences} 次",
"xpack.apm.serviceOverview.coldstartHelp": "冷启动速率指示触发无服务器功能冷启动的请求百分比。",
"xpack.apm.serviceOverview.dependenciesTableColumn": "依赖项",
"xpack.apm.serviceOverview.dependenciesTableTabLink": "查看依赖项",
@ -8081,9 +8080,6 @@
"xpack.apm.serviceOverview.errorsTable.errorMessage": "无法提取",
"xpack.apm.serviceOverview.errorsTable.loading": "正在加载……",
"xpack.apm.serviceOverview.errorsTable.noResults": "未找到错误",
"xpack.apm.serviceOverview.errorsTableColumnLastSeen": "最后看到时间",
"xpack.apm.serviceOverview.errorsTableColumnName": "名称",
"xpack.apm.serviceOverview.errorsTableColumnOccurrences": "发生次数",
"xpack.apm.serviceOverview.errorsTableLinkText": "查看错误",
"xpack.apm.serviceOverview.errorsTableTitle": "错误",
"xpack.apm.serviceOverview.instancesTable.actionMenus.container.subtitle": "查看此容器的日志和指标以获取进一步详情。",

View file

@ -0,0 +1,71 @@
/*
* 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 { apm, timerange } from '@elastic/apm-synthtrace';
import type { ApmSynthtraceEsClient } from '@elastic/apm-synthtrace';
export const config = {
firstTransaction: {
name: 'GET /apple 🍎 ',
successRate: 75,
failureRate: 25,
},
secondTransaction: {
name: 'GET /banana 🍌',
successRate: 50,
failureRate: 50,
},
};
export async function generateData({
synthtraceEsClient,
serviceName,
start,
end,
}: {
synthtraceEsClient: ApmSynthtraceEsClient;
serviceName: string;
start: number;
end: number;
}) {
const serviceGoProdInstance = apm.service(serviceName, 'production', 'go').instance('instance-a');
const interval = '1m';
const { firstTransaction, secondTransaction } = config;
const documents = [firstTransaction, secondTransaction].map((transaction) => {
return timerange(start, end)
.interval(interval)
.rate(transaction.successRate)
.generator((timestamp) =>
serviceGoProdInstance
.transaction(transaction.name)
.timestamp(timestamp)
.duration(1000)
.success()
)
.merge(
timerange(start, end)
.interval(interval)
.rate(transaction.failureRate)
.generator((timestamp) =>
serviceGoProdInstance
.transaction(transaction.name)
.errors(
serviceGoProdInstance
.error('Error 1', transaction.name, 'Error test')
.timestamp(timestamp)
)
.duration(1000)
.timestamp(timestamp)
.failure()
)
);
});
await synthtraceEsClient.index(documents);
}

View file

@ -0,0 +1,211 @@
/*
* 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 expect from '@kbn/expect';
import {
APIClientRequestParamsOf,
APIReturnType,
} from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
import { RecursivePartial } from '@kbn/apm-plugin/typings/common';
import { sumBy, first, last } from 'lodash';
import { isFiniteNumber } from '@kbn/apm-plugin/common/utils/is_finite_number';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import { config, generateData } from './generate_data';
type ErroneousTransactions =
APIReturnType<'GET /internal/apm/services/{serviceName}/errors/{groupId}/top_erroneous_transactions'>;
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const synthtraceEsClient = getService('synthtraceEsClient');
const serviceName = 'synth-go';
const start = new Date('2021-01-01T00:00:00.000Z').getTime();
const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1;
async function callApi(
overrides?: RecursivePartial<
APIClientRequestParamsOf<'GET /internal/apm/services/{serviceName}/errors/{groupId}/top_erroneous_transactions'>['params']
>
) {
const response = await apmApiClient.readUser({
endpoint:
'GET /internal/apm/services/{serviceName}/errors/{groupId}/top_erroneous_transactions',
params: {
path: {
serviceName,
groupId: 'test',
...overrides?.path,
},
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
environment: 'ENVIRONMENT_ALL',
kuery: '',
offset: undefined,
numBuckets: 15,
...overrides?.query,
},
},
});
return response;
}
registry.when('when data is not loaded', { config: 'basic', archives: [] }, () => {
it('handles the empty state', async () => {
const response = await callApi();
expect(response.status).to.be(200);
expect(response.body.topErroneousTransactions).to.be.empty();
});
});
registry.when(
'when data is loaded',
{ config: 'basic', archives: ['apm_mappings_only_8.0.0'] },
() => {
const {
firstTransaction: { name: firstTransactionName, failureRate: firstTransactionFailureRate },
secondTransaction: {
name: secondTransactionName,
failureRate: secondTransactionFailureRate,
},
} = config;
describe('returns the correct data', () => {
before(async () => {
await generateData({ serviceName, start, end, synthtraceEsClient });
});
after(() => synthtraceEsClient.clean());
describe('without comparison', () => {
const numberOfBuckets = 15;
let erroneousTransactions: ErroneousTransactions;
before(async () => {
const response = await callApi({
path: { groupId: '0000000000000000000000Error test' },
});
erroneousTransactions = response.body;
});
it('displays the correct number of occurrences', () => {
const { topErroneousTransactions } = erroneousTransactions;
expect(topErroneousTransactions.length).to.be(2);
const firstTransaction = topErroneousTransactions.find(
(x) => x.transactionName === firstTransactionName
);
expect(firstTransaction).to.not.be(undefined);
expect(firstTransaction?.occurrences).to.be(
firstTransactionFailureRate * numberOfBuckets
);
const secondTransaction = topErroneousTransactions.find(
(x) => x.transactionName === secondTransactionName
);
expect(secondTransaction).to.not.be(undefined);
expect(secondTransaction?.occurrences).to.be(
secondTransactionFailureRate * numberOfBuckets
);
});
it('displays the correct number of occurrences in time series', () => {
const { topErroneousTransactions } = erroneousTransactions;
const firstTransaction = topErroneousTransactions.find(
(x) => x.transactionName === firstTransactionName
);
const firstErrorCount = sumBy(firstTransaction?.currentPeriodTimeseries, 'y');
expect(firstErrorCount).to.be(firstTransactionFailureRate * numberOfBuckets);
const secondTransaction = topErroneousTransactions.find(
(x) => x.transactionName === secondTransactionName
);
const secondErrorCount = sumBy(secondTransaction?.currentPeriodTimeseries, 'y');
expect(secondErrorCount).to.be(secondTransactionFailureRate * numberOfBuckets);
});
});
describe('with comparison', () => {
describe('when there are data for the time periods', () => {
let erroneousTransactions: ErroneousTransactions;
before(async () => {
const fiveMinutes = 5 * 60 * 1000;
const response = await callApi({
path: { groupId: '0000000000000000000000Error test' },
query: {
start: new Date(end - fiveMinutes).toISOString(),
end: new Date(end).toISOString(),
offset: '5m',
},
});
erroneousTransactions = response.body;
});
it('returns some data', () => {
const { topErroneousTransactions } = erroneousTransactions;
const hasCurrentPeriodData = topErroneousTransactions[0].currentPeriodTimeseries.some(
({ y }) => isFiniteNumber(y)
);
const hasPreviousPeriodData =
topErroneousTransactions[0].previousPeriodTimeseries.some(({ y }) =>
isFiniteNumber(y)
);
expect(hasCurrentPeriodData).to.be(true);
expect(hasPreviousPeriodData).to.be(true);
});
it('has the same start time for both periods', () => {
const { topErroneousTransactions } = erroneousTransactions;
expect(first(topErroneousTransactions[0].currentPeriodTimeseries)?.x).to.be(
first(topErroneousTransactions[0].previousPeriodTimeseries)?.x
);
});
it('has same end time for both periods', () => {
const { topErroneousTransactions } = erroneousTransactions;
expect(last(topErroneousTransactions[0].currentPeriodTimeseries)?.x).to.be(
last(topErroneousTransactions[0].previousPeriodTimeseries)?.x
);
});
it('returns same number of buckets for both periods', () => {
const { topErroneousTransactions } = erroneousTransactions;
expect(topErroneousTransactions[0].currentPeriodTimeseries.length).to.be(
topErroneousTransactions[0].previousPeriodTimeseries.length
);
});
});
describe('when there are no data for the time period', () => {
it('returns an empty array', async () => {
const response = await callApi({
path: { groupId: '0000000000000000000000Error test' },
query: {
start: '2021-01-03T00:00:00.000Z',
end: '2021-01-03T00:15:00.000Z',
offset: '1d',
},
});
const {
body: { topErroneousTransactions },
} = response;
expect(topErroneousTransactions).to.be.empty();
});
});
});
});
}
);
}

View file

@ -0,0 +1,74 @@
/*
* 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 { apm, timerange } from '@elastic/apm-synthtrace';
import type { ApmSynthtraceEsClient } from '@elastic/apm-synthtrace';
export const config = {
firstTransaction: {
name: 'GET /apple 🍎',
successRate: 75,
failureRate: 25,
},
secondTransaction: {
name: 'GET /banana 🍌',
successRate: 50,
failureRate: 50,
},
};
export async function generateData({
synthtraceEsClient,
serviceName,
start,
end,
}: {
synthtraceEsClient: ApmSynthtraceEsClient;
serviceName: string;
start: number;
end: number;
}) {
const serviceGoProdInstance = apm.service(serviceName, 'production', 'go').instance('instance-a');
const interval = '1m';
const { firstTransaction, secondTransaction } = config;
const documents = [firstTransaction, secondTransaction].map((transaction, index) => {
return timerange(start, end)
.interval(interval)
.rate(transaction.successRate)
.generator((timestamp) =>
serviceGoProdInstance
.transaction(transaction.name)
.timestamp(timestamp)
.duration(1000)
.success()
)
.merge(
timerange(start, end)
.interval(interval)
.rate(transaction.failureRate)
.generator((timestamp) =>
serviceGoProdInstance
.transaction(transaction.name)
.errors(
serviceGoProdInstance
.error(`Error 1 transaction ${transaction.name}`)
.timestamp(timestamp),
serviceGoProdInstance
.error(`Error 2 transaction ${transaction.name}`)
.timestamp(timestamp)
)
.duration(1000)
.timestamp(timestamp)
.failure()
)
);
});
await synthtraceEsClient.index(documents);
}

View file

@ -0,0 +1,111 @@
/*
* 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 expect from '@kbn/expect';
import {
APIClientRequestParamsOf,
APIReturnType,
} from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
import { RecursivePartial } from '@kbn/apm-plugin/typings/common';
import moment from 'moment';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import { config, generateData } from './generate_data';
type ErrorGroups =
APIReturnType<'GET /internal/apm/services/{serviceName}/errors/groups/main_statistics_by_transaction_name'>['errorGroups'];
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const synthtraceEsClient = getService('synthtraceEsClient');
const serviceName = 'synth-go';
const start = new Date('2021-01-01T00:00:00.000Z').getTime();
const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1;
async function callApi(
overrides?: RecursivePartial<
APIClientRequestParamsOf<'GET /internal/apm/services/{serviceName}/errors/groups/main_statistics_by_transaction_name'>['params']
>
) {
return await apmApiClient.readUser({
endpoint:
'GET /internal/apm/services/{serviceName}/errors/groups/main_statistics_by_transaction_name',
params: {
path: { serviceName, ...overrides?.path },
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
environment: 'ENVIRONMENT_ALL',
kuery: '',
maxNumberOfErrorGroups: 5,
transactionType: 'request',
transactionName: '',
...overrides?.query,
},
},
});
}
registry.when('when data is not loaded', { config: 'basic', archives: [] }, () => {
it('handles empty state', async () => {
const response = await callApi();
expect(response.status).to.be(200);
expect(response.body.errorGroups).to.empty();
});
});
registry.when(
'when data is loaded',
{ config: 'basic', archives: ['apm_mappings_only_8.0.0'] },
() => {
describe('top errors for transaction', () => {
const {
firstTransaction: {
name: firstTransactionName,
failureRate: firstTransactionFailureRate,
},
} = config;
before(async () => {
await generateData({ serviceName, start, end, synthtraceEsClient });
});
after(() => synthtraceEsClient.clean());
describe('returns the correct data', () => {
let errorGroups: ErrorGroups;
before(async () => {
const response = await callApi({ query: { transactionName: firstTransactionName } });
errorGroups = response.body.errorGroups;
});
it('returns correct number of errors and error data', () => {
const numberOfBuckets = 15;
expect(errorGroups.length).to.equal(2);
const firstErrorId = `Error 1 transaction ${firstTransactionName}`;
const firstError = errorGroups.find((x) => x.groupId === firstErrorId);
expect(firstError).to.not.be(undefined);
expect(firstError?.groupId).to.be(firstErrorId);
expect(firstError?.name).to.be(firstErrorId);
expect(firstError?.occurrences).to.be(firstTransactionFailureRate * numberOfBuckets);
expect(firstError?.lastSeen).to.be(moment(end).startOf('minute').valueOf());
const secondErrorId = `Error 2 transaction ${firstTransactionName}`;
const secondError = errorGroups.find((x) => x.groupId === secondErrorId);
expect(secondError).to.not.be(undefined);
expect(secondError?.groupId).to.be(secondErrorId);
expect(secondError?.name).to.be(secondErrorId);
expect(secondError?.occurrences).to.be(firstTransactionFailureRate * numberOfBuckets);
expect(secondError?.lastSeen).to.be(moment(end).startOf('minute').valueOf());
});
});
});
}
);
}