Fix preview chart in APM error rate and error count rules (#159544)

Resolves the following for APM Failed transaction rate and APM Error
count rules:
- https://github.com/elastic/kibana/issues/152218
- https://github.com/elastic/kibana/issues/155489

Improvements in the preview chart:
- Showing data grouped by selected group by fields
- Showing 5 groups from each bucket
- Showing last x units (minutes, hours, etc.) data
- Added loading indicator
- Showing tooltip
- Always showing legend
- Max Y is slightly higher than maximum of highest y value or threshold
- Adjusting chart to always show the threshold line
- Fixed
- When removing some fields like transaction type, transaction name,
chart showed no data
- Hiding a particular group (by clicking on legend item), readjusted max
Y value, but same was not reset to original when enabling the group back
This commit is contained in:
Bena Kansara 2023-06-19 15:56:38 +02:00 committed by GitHub
parent cfa46e473a
commit 6dbf5483cf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 1318 additions and 383 deletions

View file

@ -0,0 +1,37 @@
/*
* 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 { union } from 'lodash';
import { ApmRuleType } from './apm_rule_types';
import {
SERVICE_ENVIRONMENT,
SERVICE_NAME,
TRANSACTION_TYPE,
} from '../es_fields/apm';
export const getAllGroupByFields = (
ruleType: string,
ruleParamsGroupByFields: string[] | undefined = []
) => {
let predefinedGroupByFields: string[] = [];
switch (ruleType) {
case ApmRuleType.TransactionDuration:
case ApmRuleType.TransactionErrorRate:
predefinedGroupByFields = [
SERVICE_NAME,
SERVICE_ENVIRONMENT,
TRANSACTION_TYPE,
];
break;
case ApmRuleType.ErrorCount:
predefinedGroupByFields = [SERVICE_NAME, SERVICE_ENVIRONMENT];
break;
}
return union(predefinedGroupByFields, ruleParamsGroupByFields);
};

View file

@ -18,7 +18,11 @@ import { EuiFormRow } from '@elastic/eui';
import { EuiSpacer } from '@elastic/eui';
import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values';
import { asInteger } from '../../../../../common/utils/formatters';
import { useFetcher } from '../../../../hooks/use_fetcher';
import {
FETCH_STATUS,
isPending,
useFetcher,
} from '../../../../hooks/use_fetcher';
import { createCallApmApi } from '../../../../services/rest/create_call_apm_api';
import { ChartPreview } from '../../ui_components/chart_preview';
import {
@ -36,6 +40,11 @@ import {
TRANSACTION_NAME,
ERROR_GROUP_ID,
} from '../../../../../common/es_fields/apm';
import {
ErrorState,
LoadingState,
NoDataState,
} from '../../ui_components/chart_preview/chart_preview_helper';
export interface RuleParams {
windowSize?: number;
@ -72,13 +81,13 @@ export function ErrorCountRuleType(props: Props) {
}
);
const { data } = useFetcher(
const { data, status } = useFetcher(
(callApmApi) => {
const { interval, start, end } = getIntervalAndTimeRange({
windowSize: params.windowSize,
windowUnit: params.windowUnit,
});
if (interval && start && end) {
if (params.windowSize && start && end) {
return callApmApi(
'GET /internal/apm/rule_types/error_count/chart_preview',
{
@ -90,6 +99,7 @@ export function ErrorCountRuleType(props: Props) {
interval,
start,
end,
groupBy: params.groupBy,
},
},
}
@ -102,6 +112,7 @@ export function ErrorCountRuleType(props: Props) {
params.environment,
params.serviceName,
params.errorGroupingKey,
params.groupBy,
]
);
@ -162,16 +173,28 @@ export function ErrorCountRuleType(props: Props) {
/>,
];
// hide preview chart until https://github.com/elastic/kibana/pull/156625 gets merged
const showChartPreview = false;
const chartPreview = showChartPreview ? (
const errorCountChartPreview = data?.errorCountChartPreview;
const series = errorCountChartPreview?.series ?? [];
const hasData = series.length > 0;
const totalGroups = errorCountChartPreview?.totalGroups ?? 0;
const chartPreview = isPending(status) ? (
<LoadingState />
) : !hasData ? (
<NoDataState />
) : status === FETCH_STATUS.SUCCESS ? (
<ChartPreview
series={[{ data: data?.errorCountChartPreview ?? [] }]}
series={series}
threshold={params.threshold}
yTickFormat={asInteger}
uiSettings={services.uiSettings}
timeSize={params.windowSize}
timeUnit={params.windowUnit}
totalGroups={totalGroups}
/>
) : null;
) : (
<ErrorState />
);
const groupAlertsBy = (
<>

View file

@ -155,6 +155,7 @@ export function TransactionDurationRuleType(props: Props) {
threshold={thresholdMs}
yTickFormat={yTickFormat}
uiSettings={services.uiSettings}
totalGroups={0} // TODO: will be updated in https://github.com/elastic/kibana/pull/158439
/>
) : null;

View file

@ -18,7 +18,11 @@ import { EuiFormRow } from '@elastic/eui';
import { EuiSpacer } from '@elastic/eui';
import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values';
import { asPercent } from '../../../../../common/utils/formatters';
import { useFetcher } from '../../../../hooks/use_fetcher';
import {
FETCH_STATUS,
isPending,
useFetcher,
} from '../../../../hooks/use_fetcher';
import { createCallApmApi } from '../../../../services/rest/create_call_apm_api';
import { ChartPreview } from '../../ui_components/chart_preview';
import {
@ -37,6 +41,11 @@ import {
TRANSACTION_TYPE,
TRANSACTION_NAME,
} from '../../../../../common/es_fields/apm';
import {
ErrorState,
LoadingState,
NoDataState,
} from '../../ui_components/chart_preview/chart_preview_helper';
export interface RuleParams {
windowSize?: number;
@ -74,15 +83,13 @@ export function TransactionErrorRateRuleType(props: Props) {
}
);
const thresholdAsPercent = (params.threshold ?? 0) / 100;
const { data } = useFetcher(
const { data, status } = useFetcher(
(callApmApi) => {
const { interval, start, end } = getIntervalAndTimeRange({
windowSize: params.windowSize,
windowUnit: params.windowUnit,
});
if (interval && start && end) {
if (params.windowSize && start && end) {
return callApmApi(
'GET /internal/apm/rule_types/transaction_error_rate/chart_preview',
{
@ -95,6 +102,7 @@ export function TransactionErrorRateRuleType(props: Props) {
interval,
start,
end,
groupBy: params.groupBy,
},
},
}
@ -108,6 +116,7 @@ export function TransactionErrorRateRuleType(props: Props) {
params.serviceName,
params.windowSize,
params.windowUnit,
params.groupBy,
]
);
@ -171,16 +180,28 @@ export function TransactionErrorRateRuleType(props: Props) {
/>,
];
// hide preview chart until https://github.com/elastic/kibana/pull/156625 gets merged
const showChartPreview = false;
const chartPreview = showChartPreview ? (
const errorRateChartPreview = data?.errorRateChartPreview;
const series = errorRateChartPreview?.series ?? [];
const hasData = series.length > 0;
const totalGroups = errorRateChartPreview?.totalGroups ?? 0;
const chartPreview = isPending(status) ? (
<LoadingState />
) : !hasData ? (
<NoDataState />
) : status === FETCH_STATUS.SUCCESS ? (
<ChartPreview
series={[{ data: data?.errorRateChartPreview ?? [] }]}
yTickFormat={(d: number | null) => asPercent(d, 1)}
threshold={thresholdAsPercent}
series={series}
yTickFormat={(d: number | null) => asPercent(d, 100)}
threshold={params.threshold}
uiSettings={services.uiSettings}
timeSize={params.windowSize}
timeUnit={params.windowUnit}
totalGroups={totalGroups}
/>
) : null;
) : (
<ErrorState />
);
const groupAlertsBy = (
<>

View file

@ -0,0 +1,122 @@
/*
* 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 { EuiLoadingChart } from '@elastic/eui';
import { EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { Coordinate } from '../../../../../typings/timeseries';
export const TIME_LABELS = {
s: i18n.translate('xpack.apm.alerts.timeLabels.seconds', {
defaultMessage: 'seconds',
}),
m: i18n.translate('xpack.apm.alerts.timeLabels.minutes', {
defaultMessage: 'minutes',
}),
h: i18n.translate('xpack.apm.alerts.timeLabels.hours', {
defaultMessage: 'hours',
}),
d: i18n.translate('xpack.apm.alerts.timeLabels.days', {
defaultMessage: 'days',
}),
};
export const getDomain = (
series: Array<{ name?: string; data: Coordinate[] }>
) => {
const xValues = series.flatMap((item) => item.data.map((d) => d.x));
const yValues = series.flatMap((item) => item.data.map((d) => d.y || 0));
return {
xMax: Math.max(...xValues),
xMin: Math.min(...xValues),
yMax: Math.max(...yValues),
yMin: Math.min(...yValues),
};
};
const EmptyContainer: React.FC = ({ children }) => (
<div
style={{
width: '100%',
height: 150,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
{children}
</div>
);
export function NoDataState() {
return (
<EmptyContainer>
<EuiText color="subdued" data-test-subj="noChartData">
<FormattedMessage
id="xpack.apm.alerts.charts.noDataMessage"
defaultMessage="No chart data available"
/>
</EuiText>
</EmptyContainer>
);
}
export function LoadingState() {
return (
<EmptyContainer>
<EuiText color="subdued" data-test-subj="loadingData">
<EuiLoadingChart size="m" />
</EuiText>
</EmptyContainer>
);
}
export function ErrorState() {
return (
<EmptyContainer>
<EuiText color="subdued" data-test-subj="chartErrorState">
<FormattedMessage
id="xpack.apm.alerts.charts.errorMessage"
defaultMessage="Uh oh, something went wrong"
/>
</EuiText>
</EmptyContainer>
);
}
interface PreviewChartLabel {
lookback: number;
timeLabel: string;
displayedGroups: number;
totalGroups: number;
}
export function TimeLabelForData({
lookback,
timeLabel,
displayedGroups,
totalGroups,
}: PreviewChartLabel) {
return (
<div style={{ textAlign: 'center' }}>
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.apm.alerts.timeLabelForData"
defaultMessage="Last {lookback} {timeLabel} of data, showing {displayedGroups}/{totalGroups} groups"
values={{
lookback,
timeLabel,
displayedGroups,
totalGroups,
}}
/>
</EuiText>
</div>
);
}

View file

@ -18,19 +18,32 @@ import {
ScaleType,
Settings,
TickFormatter,
TooltipProps,
} from '@elastic/charts';
import { EuiSpacer } from '@elastic/eui';
import React, { useState } from 'react';
import React, { useMemo } from 'react';
import { IUiSettingsClient } from '@kbn/core/public';
import { Coordinate } from '../../../../../typings/timeseries';
import { TimeUnitChar } from '@kbn/observability-plugin/common';
import { UI_SETTINGS } from '@kbn/data-plugin/public';
import moment from 'moment';
import { useTheme } from '../../../../hooks/use_theme';
import { getTimeZone } from '../../../shared/charts/helper/timezone';
import {
TimeLabelForData,
TIME_LABELS,
getDomain,
} from './chart_preview_helper';
import { ALERT_PREVIEW_BUCKET_SIZE } from '../../utils/helper';
import { Coordinate } from '../../../../../typings/timeseries';
interface ChartPreviewProps {
yTickFormat?: TickFormatter;
threshold: number;
uiSettings?: IUiSettingsClient;
series: Array<{ name?: string; data: Coordinate[] }>;
timeSize?: number;
timeUnit?: TimeUnitChar;
totalGroups: number;
}
export function ChartPreview({
@ -38,21 +51,12 @@ export function ChartPreview({
threshold,
uiSettings,
series,
timeSize = 5,
timeUnit = 'm',
totalGroups,
}: ChartPreviewProps) {
const [yMax, setYMax] = useState(threshold);
const theme = useTheme();
const thresholdOpacity = 0.3;
const timestamps = series.flatMap(({ data }) => data.map(({ x }) => x));
const xMin = Math.min(...timestamps);
const xMax = Math.max(...timestamps);
const xFormatter = niceTimeFormatter([xMin, xMax]);
function updateYMax() {
// Make the maximum Y value either the actual max or 20% more than the threshold
const values = series.flatMap(({ data }) => data.map((d) => d.y ?? 0));
setYMax(Math.max(...values, threshold * 1.2));
}
const style = {
fill: theme.eui.euiColorVis2,
@ -64,36 +68,69 @@ export function ChartPreview({
opacity: thresholdOpacity,
};
const DEFAULT_DATE_FORMAT = 'Y-MM-DD HH:mm:ss';
const tooltipProps: TooltipProps = {
headerFormatter: ({ value }) => {
const dateFormat =
(uiSettings && uiSettings.get(UI_SETTINGS.DATE_FORMAT)) ||
DEFAULT_DATE_FORMAT;
return moment(value).format(dateFormat);
},
};
const barSeries = useMemo(() => {
return series.flatMap((serie) =>
serie.data.map((point) => ({ ...point, groupBy: serie.name }))
);
}, [series]);
const timeZone = getTimeZone(uiSettings);
const legendSize =
series.length > 1 ? Math.ceil(series.length / 2) * 30 : series.length * 35;
const chartSize = 150;
const { yMin, yMax, xMin, xMax } = getDomain(series);
const chartDomain = {
max: Math.max(yMax, threshold) * 1.1, // Add 10% headroom.
min: Math.min(yMin, threshold) * 0.9, // Add 10% headroom.
};
const dateFormatter = useMemo(
() => niceTimeFormatter([xMin, xMax]),
[xMin, xMax]
);
const lookback = timeSize * ALERT_PREVIEW_BUCKET_SIZE;
const timeLabel = TIME_LABELS[timeUnit as keyof typeof TIME_LABELS];
const rectDataValues: RectAnnotationDatum[] = [
{
coordinates: {
x0: null,
x1: null,
x0: xMin,
x1: xMax,
y0: threshold,
y1: null,
y1: chartDomain.max,
},
},
];
const timeZone = getTimeZone(uiSettings);
const legendSize = Math.ceil(series.length / 2) * 30;
const chartSize = 150;
return (
<>
<EuiSpacer size="m" />
<Chart
size={{
height: series.length > 1 ? chartSize + legendSize : chartSize,
height: chartSize + legendSize,
}}
data-test-subj="ChartPreview"
>
<Settings
tooltip="none"
showLegend={series.length > 1}
tooltip={tooltipProps}
showLegend={true}
legendPosition={'bottom'}
legendSize={legendSize}
onLegendItemClick={updateYMax}
/>
<LineAnnotation
dataValues={[{ dataValue: threshold }]}
@ -111,30 +148,44 @@ export function ChartPreview({
<Axis
id="chart_preview_x_axis"
position={Position.Bottom}
showOverlappingTicks
tickFormat={xFormatter}
showOverlappingTicks={true}
tickFormat={dateFormatter}
/>
<Axis
id="chart_preview_y_axis"
position={Position.Left}
tickFormat={yTickFormat}
ticks={5}
domain={{ max: yMax, min: 0 }}
domain={chartDomain}
/>
<BarSeries
id="apm-chart-preview"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="x"
yAccessors={['y']}
splitSeriesAccessors={['groupBy']}
data={barSeries}
barSeriesStyle={{
rectBorder: {
strokeWidth: 1,
visible: true,
},
rect: {
opacity: 1,
},
}}
timeZone={timeZone}
/>
{series.map(({ name, data }, index) => (
<BarSeries
key={index}
timeZone={timeZone}
data={data}
id={`chart_preview_bar_series_${name || index}`}
name={name}
xAccessor="x"
xScaleType={ScaleType.Time}
yAccessors={['y']}
yScaleType={ScaleType.Linear}
/>
))}
</Chart>
{series.length > 0 && (
<TimeLabelForData
lookback={lookback}
timeLabel={timeLabel}
displayedGroups={series.length}
totalGroups={totalGroups}
/>
)}
</>
);
}

View file

@ -16,7 +16,7 @@ export interface AlertMetadata {
end?: string;
}
const BUCKET_SIZE = 20;
export const ALERT_PREVIEW_BUCKET_SIZE = 5;
export function getIntervalAndTimeRange({
windowSize,
@ -28,7 +28,8 @@ export function getIntervalAndTimeRange({
const end = Date.now();
const start =
end -
moment.duration(windowSize, windowUnit).asMilliseconds() * BUCKET_SIZE;
moment.duration(windowSize, windowUnit).asMilliseconds() *
ALERT_PREVIEW_BUCKET_SIZE;
return {
interval: `${windowSize}${windowUnit}`,

View file

@ -6,15 +6,13 @@
*/
import * as t from 'io-ts';
import { Coordinate } from '../../../typings/timeseries';
import {
getTransactionDurationChartPreview,
TransactionDurationChartPreviewResponse,
} from './rule_types/transaction_duration/get_transaction_duration_chart_preview';
import { getTransactionErrorCountChartPreview } from './rule_types/error_count/get_error_count_chart_preview';
import {
getTransactionErrorRateChartPreview,
TransactionErrorRateChartPreviewResponse,
} from './rule_types/transaction_error_rate/get_transaction_error_rate_chart_preview';
import { getTransactionErrorRateChartPreview } from './rule_types/transaction_error_rate/get_transaction_error_rate_chart_preview';
import { createApmServerRoute } from '../apm_routes/create_apm_server_route';
import { environmentRt, rangeRt } from '../default_api_types';
import { AggregationType } from '../../../common/rules/apm_rule_types';
@ -37,8 +35,21 @@ const alertParamsRt = t.intersection([
t.type({
interval: t.string,
}),
t.partial({
groupBy: t.array(t.string),
}),
]);
export interface PreviewChartResponseItem {
name: string;
data: Coordinate[];
}
export interface PreviewChartResponse {
series: PreviewChartResponseItem[];
totalGroups: number;
}
export type AlertParams = t.TypeOf<typeof alertParamsRt>;
const transactionErrorRateChartPreview = createApmServerRoute({
@ -48,7 +59,7 @@ const transactionErrorRateChartPreview = createApmServerRoute({
handler: async (
resources
): Promise<{
errorRateChartPreview: TransactionErrorRateChartPreviewResponse;
errorRateChartPreview: PreviewChartResponse;
}> => {
const apmEventClient = await getApmEventClient(resources);
const { params, config } = resources;
@ -70,7 +81,9 @@ const transactionErrorCountChartPreview = createApmServerRoute({
options: { tags: ['access:apm'] },
handler: async (
resources
): Promise<{ errorCountChartPreview: Array<{ x: number; y: number }> }> => {
): Promise<{
errorCountChartPreview: PreviewChartResponse;
}> => {
const apmEventClient = await getApmEventClient(resources);
const { params } = resources;
@ -112,7 +125,6 @@ const transactionDurationChartPreview = createApmServerRoute({
export const alertsChartPreviewRouteRepository = {
...transactionErrorRateChartPreview,
...transactionDurationChartPreview,
...transactionErrorCountChartPreview,
...transactionDurationChartPreview,
};

View file

@ -9,16 +9,19 @@ import { rangeQuery, termQuery } from '@kbn/observability-plugin/server';
import { ProcessorEvent } from '@kbn/observability-plugin/common';
import {
ERROR_GROUP_ID,
PROCESSOR_EVENT,
SERVICE_NAME,
} from '../../../../../common/es_fields/apm';
import { AlertParams } from '../../route';
import { AlertParams, PreviewChartResponse } from '../../route';
import { environmentQuery } from '../../../../../common/utils/environment_query';
import { APMEventClient } from '../../../../lib/helpers/create_es_client/create_apm_event_client';
export type TransactionErrorCountChartPreviewResponse = Array<{
x: number;
y: number;
}>;
import { getGroupByTerms } from '../utils/get_groupby_terms';
import { getAllGroupByFields } from '../../../../../common/rules/get_all_groupby_fields';
import { ApmRuleType } from '../../../../../common/rules/apm_rule_types';
import {
BarSeriesDataMap,
getFilteredBarSeries,
} from '../utils/get_filtered_series_for_preview_chart';
export async function getTransactionErrorCountChartPreview({
apmEventClient,
@ -26,17 +29,34 @@ export async function getTransactionErrorCountChartPreview({
}: {
apmEventClient: APMEventClient;
alertParams: AlertParams;
}): Promise<TransactionErrorCountChartPreviewResponse> {
const { serviceName, environment, errorGroupingKey, interval, start, end } =
alertParams;
}): Promise<PreviewChartResponse> {
const {
serviceName,
environment,
errorGroupingKey,
interval,
start,
end,
groupBy: groupByFields,
} = alertParams;
const allGroupByFields = getAllGroupByFields(
ApmRuleType.ErrorCount,
groupByFields
);
const query = {
bool: {
filter: [
...termQuery(SERVICE_NAME, serviceName),
...termQuery(ERROR_GROUP_ID, errorGroupingKey),
...termQuery(SERVICE_NAME, serviceName, {
queryEmptyString: false,
}),
...termQuery(ERROR_GROUP_ID, errorGroupingKey, {
queryEmptyString: false,
}),
...rangeQuery(start, end),
...environmentQuery(environment),
{ term: { [PROCESSOR_EVENT]: ProcessorEvent.error } },
],
},
};
@ -51,6 +71,15 @@ export async function getTransactionErrorCountChartPreview({
max: end,
},
},
aggs: {
series: {
multi_terms: {
terms: getGroupByTerms(allGroupByFields),
size: 1000,
order: { _count: 'desc' as const },
},
},
},
},
};
@ -65,13 +94,37 @@ export async function getTransactionErrorCountChartPreview({
);
if (!resp.aggregations) {
return [];
return { series: [], totalGroups: 0 };
}
return resp.aggregations.timeseries.buckets.map((bucket) => {
return {
x: bucket.key,
y: bucket.doc_count,
};
});
const seriesDataMap = resp.aggregations.timeseries.buckets.reduce(
(acc, bucket) => {
const x = bucket.key;
bucket.series.buckets.forEach((seriesBucket) => {
const bucketKey = seriesBucket.key.join('_');
const y = seriesBucket.doc_count;
if (acc[bucketKey]) {
acc[bucketKey].push({ x, y });
} else {
acc[bucketKey] = [{ x, y }];
}
});
return acc;
},
{} as BarSeriesDataMap
);
const series = Object.keys(seriesDataMap).map((key) => ({
name: key,
data: seriesDataMap[key],
}));
const filteredSeries = getFilteredBarSeries(series);
return {
series: filteredSeries,
totalGroups: series.length,
};
}

View file

@ -50,6 +50,7 @@ import {
} from '../get_service_group_fields';
import { getGroupByTerms } from '../utils/get_groupby_terms';
import { getGroupByActionVariables } from '../utils/get_groupby_action_variables';
import { getAllGroupByFields } from '../../../../../common/rules/get_all_groupby_fields';
const ruleTypeConfig = RULE_TYPES_CONFIG[ApmRuleType.ErrorCount];
@ -96,10 +97,9 @@ export function registerErrorCountRuleType({
spaceId,
startedAt,
}) => {
const predefinedGroupby = [SERVICE_NAME, SERVICE_ENVIRONMENT];
const allGroupbyFields = Array.from(
new Set([...predefinedGroupby, ...(ruleParams.groupBy ?? [])])
const allGroupByFields = getAllGroupByFields(
ApmRuleType.ErrorCount,
ruleParams.groupBy
);
const config = await firstValueFrom(config$);
@ -145,7 +145,7 @@ export function registerErrorCountRuleType({
aggs: {
error_counts: {
multi_terms: {
terms: getGroupByTerms(allGroupbyFields),
terms: getGroupByTerms(allGroupByFields),
size: 1000,
order: { _count: 'desc' as const },
},
@ -164,7 +164,7 @@ export function registerErrorCountRuleType({
response.aggregations?.error_counts.buckets.map((bucket) => {
const groupByFields = bucket.key.reduce(
(obj, bucketKey, bucketIndex) => {
obj[allGroupbyFields[bucketIndex]] = bucketKey;
obj[allGroupByFields[bucketIndex]] = bucketKey;
return obj;
},
{} as Record<string, string>

View file

@ -6,30 +6,29 @@
*/
import { rangeQuery, termQuery } from '@kbn/observability-plugin/server';
import { ApmRuleType } from '../../../../../common/rules/apm_rule_types';
import {
SERVICE_NAME,
TRANSACTION_TYPE,
TRANSACTION_NAME,
EVENT_OUTCOME,
} from '../../../../../common/es_fields/apm';
import { environmentQuery } from '../../../../../common/utils/environment_query';
import { AlertParams } from '../../route';
import { AlertParams, PreviewChartResponse } from '../../route';
import {
getSearchTransactionsEvents,
getDocumentTypeFilterForTransactions,
getProcessorEventForTransactions,
} from '../../../../lib/helpers/transactions';
import {
calculateFailedTransactionRate,
getOutcomeAggregation,
} from '../../../../lib/helpers/transaction_error_rate';
import { APMConfig } from '../../../..';
import { APMEventClient } from '../../../../lib/helpers/create_es_client/create_apm_event_client';
import { ApmDocumentType } from '../../../../../common/document_type';
export type TransactionErrorRateChartPreviewResponse = Array<{
x: number;
y: number;
}>;
import { EventOutcome } from '../../../../../common/event_outcome';
import { getGroupByTerms } from '../utils/get_groupby_terms';
import { getAllGroupByFields } from '../../../../../common/rules/get_all_groupby_fields';
import {
BarSeriesDataMap,
getFilteredBarSeries,
} from '../utils/get_filtered_series_for_preview_chart';
export async function getTransactionErrorRateChartPreview({
config,
@ -39,7 +38,7 @@ export async function getTransactionErrorRateChartPreview({
config: APMConfig;
apmEventClient: APMEventClient;
alertParams: AlertParams;
}): Promise<TransactionErrorRateChartPreviewResponse> {
}): Promise<PreviewChartResponse> {
const {
serviceName,
environment,
@ -48,16 +47,20 @@ export async function getTransactionErrorRateChartPreview({
start,
end,
transactionName,
groupBy: groupByFields,
} = alertParams;
const searchAggregatedTransactions = await getSearchTransactionsEvents({
config,
apmEventClient,
kuery: '',
start,
end,
});
const allGroupByFields = getAllGroupByFields(
ApmRuleType.TransactionErrorRate,
groupByFields
);
const params = {
apm: {
events: [getProcessorEventForTransactions(searchAggregatedTransactions)],
@ -68,14 +71,25 @@ export async function getTransactionErrorRateChartPreview({
query: {
bool: {
filter: [
...termQuery(SERVICE_NAME, serviceName),
...termQuery(TRANSACTION_TYPE, transactionType),
...termQuery(TRANSACTION_NAME, transactionName),
...termQuery(SERVICE_NAME, serviceName, {
queryEmptyString: false,
}),
...termQuery(TRANSACTION_TYPE, transactionType, {
queryEmptyString: false,
}),
...termQuery(TRANSACTION_NAME, transactionName, {
queryEmptyString: false,
}),
...rangeQuery(start, end),
...environmentQuery(environment),
...getDocumentTypeFilterForTransactions(
searchAggregatedTransactions
),
{
terms: {
[EVENT_OUTCOME]: [EventOutcome.failure, EventOutcome.success],
},
},
],
},
},
@ -89,11 +103,22 @@ export async function getTransactionErrorRateChartPreview({
max: end,
},
},
aggs: getOutcomeAggregation(
searchAggregatedTransactions
? ApmDocumentType.TransactionMetric
: ApmDocumentType.TransactionEvent
),
aggs: {
series: {
multi_terms: {
terms: [...getGroupByTerms(allGroupByFields)],
size: 1000,
order: { _count: 'desc' as const },
},
aggs: {
outcomes: {
terms: {
field: EVENT_OUTCOME,
},
},
},
},
},
},
},
},
@ -105,13 +130,54 @@ export async function getTransactionErrorRateChartPreview({
);
if (!resp.aggregations) {
return [];
return { series: [], totalGroups: 0 };
}
return resp.aggregations.timeseries.buckets.map((bucket) => {
return {
x: bucket.key,
y: calculateFailedTransactionRate(bucket),
};
});
const seriesDataMap = resp.aggregations.timeseries.buckets.reduce(
(acc, bucket) => {
const x = bucket.key;
bucket.series.buckets.forEach((seriesBucket) => {
const bucketKey = seriesBucket.key.join('_');
const y = calculateErrorRate(seriesBucket.outcomes.buckets);
if (acc[bucketKey]) {
acc[bucketKey].push({ x, y });
} else {
acc[bucketKey] = [{ x, y }];
}
});
return acc;
},
{} as BarSeriesDataMap
);
const series = Object.keys(seriesDataMap).map((key) => ({
name: key,
data: seriesDataMap[key],
}));
const filteredSeries = getFilteredBarSeries(series);
return {
series: filteredSeries,
totalGroups: series.length,
};
}
const calculateErrorRate = (
buckets: Array<{
doc_count: number;
key: string | number;
}>
) => {
const failed =
buckets.find((outcomeBucket) => outcomeBucket.key === EventOutcome.failure)
?.doc_count ?? 0;
const succesful =
buckets.find((outcomeBucket) => outcomeBucket.key === EventOutcome.success)
?.doc_count ?? 0;
return (failed / (failed + succesful)) * 100;
};

View file

@ -59,6 +59,7 @@ import {
} from '../get_service_group_fields';
import { getGroupByTerms } from '../utils/get_groupby_terms';
import { getGroupByActionVariables } from '../utils/get_groupby_action_variables';
import { getAllGroupByFields } from '../../../../../common/rules/get_all_groupby_fields';
const ruleTypeConfig = RULE_TYPES_CONFIG[ApmRuleType.TransactionErrorRate];
@ -106,14 +107,9 @@ export function registerTransactionErrorRateRuleType({
params: ruleParams,
startedAt,
}) => {
const predefinedGroupby = [
SERVICE_NAME,
SERVICE_ENVIRONMENT,
TRANSACTION_TYPE,
];
const allGroupbyFields = Array.from(
new Set([...predefinedGroupby, ...(ruleParams.groupBy ?? [])])
const allGroupByFields = getAllGroupByFields(
ApmRuleType.TransactionErrorRate,
ruleParams.groupBy
);
const config = await firstValueFrom(config$);
@ -183,7 +179,7 @@ export function registerTransactionErrorRateRuleType({
aggs: {
series: {
multi_terms: {
terms: [...getGroupByTerms(allGroupbyFields)],
terms: [...getGroupByTerms(allGroupByFields)],
size: 1000,
order: { _count: 'desc' as const },
},
@ -214,7 +210,7 @@ export function registerTransactionErrorRateRuleType({
for (const bucket of response.aggregations.series.buckets) {
const groupByFields = bucket.key.reduce(
(obj, bucketKey, bucketIndex) => {
obj[allGroupbyFields[bucketIndex]] = bucketKey;
obj[allGroupByFields[bucketIndex]] = bucketKey;
return obj;
},
{} as Record<string, string>

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 { Coordinate } from '../../../../../typings/timeseries';
export type BarSeriesDataMap = Record<string, Coordinate[]>;
type BarSeriesData = Array<{ name: string; data: Coordinate[] }>;
const NUM_SERIES = 5;
export const getFilteredBarSeries = (barSeries: BarSeriesData) => {
const sortedSeries = barSeries.sort((a, b) => {
const aMax = Math.max(...a.data.map((point) => point.y as number));
const bMax = Math.max(...b.data.map((point) => point.y as number));
return bMax - aMax;
});
return sortedSeries.slice(0, NUM_SERIES);
};

View file

@ -1,250 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import archives from '../../common/fixtures/es_archiver/archives_metadata';
import { FtrProviderContext } from '../../common/ftr_provider_context';
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const archiveName = 'apm_8.0.0';
const { end } = archives[archiveName];
const start = new Date(Date.parse(end) - 600000).toISOString();
const getOptions = () => ({
params: {
query: {
start,
end,
serviceName: 'opbeans-java',
transactionType: 'request' as string | undefined,
environment: 'ENVIRONMENT_ALL',
interval: '5m',
},
},
});
registry.when(`without data loaded`, { config: 'basic', archives: [] }, () => {
it('transaction_error_rate (without data)', async () => {
const options = getOptions();
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/rule_types/transaction_error_rate/chart_preview',
...options,
});
expect(response.status).to.be(200);
expect(response.body.errorRateChartPreview).to.eql([]);
});
it('error_count (without data)', async () => {
const options = getOptions();
options.params.query.transactionType = undefined;
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview',
...options,
});
expect(response.status).to.be(200);
expect(response.body.errorCountChartPreview).to.eql([]);
});
it('transaction_duration (without data)', async () => {
const options = getOptions();
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/rule_types/transaction_duration/chart_preview',
...options,
});
expect(response.status).to.be(200);
expect(response.body.latencyChartPreview).to.eql([]);
});
});
registry.when(`with data loaded`, { config: 'basic', archives: [archiveName] }, () => {
it('transaction_error_rate (with data)', async () => {
const options = getOptions();
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/rule_types/transaction_error_rate/chart_preview',
...options,
});
expect(response.status).to.be(200);
expect(
response.body.errorRateChartPreview.some(
(item: { x: number; y: number | null }) => item.x && item.y
)
).to.equal(true);
});
it('transaction_error_rate with transaction name', async () => {
const options = {
params: {
query: {
start,
end,
serviceName: 'opbeans-java',
transactionName: 'APIRestController#product',
transactionType: 'request',
environment: 'ENVIRONMENT_ALL',
interval: '5m',
},
},
};
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/rule_types/transaction_error_rate/chart_preview',
...options,
});
expect(response.status).to.be(200);
expect(response.body.errorRateChartPreview[0]).to.eql({
x: 1627974600000,
y: 1,
});
});
it('transaction_error_rate with nonexistent transaction name', async () => {
const options = {
params: {
query: {
start,
end,
serviceName: 'opbeans-java',
transactionName: 'foo',
transactionType: 'request',
environment: 'ENVIRONMENT_ALL',
interval: '5m',
},
},
};
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/rule_types/transaction_error_rate/chart_preview',
...options,
});
expect(response.status).to.be(200);
expect(
response.body.errorRateChartPreview.every(
(item: { x: number; y: number | null }) => item.y === null
)
).to.equal(true);
});
it('error_count (with data)', async () => {
const options = getOptions();
options.params.query.transactionType = undefined;
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview',
...options,
});
expect(response.status).to.be(200);
expect(
response.body.errorCountChartPreview.some(
(item: { x: number; y: number | null }) => item.x && item.y
)
).to.equal(true);
});
it('error_count with error grouping key', async () => {
const options = {
params: {
query: {
start,
end,
serviceName: 'opbeans-java',
errorGroupingKey: 'd16d39e7fa133b8943cea035430a7b4e',
environment: 'ENVIRONMENT_ALL',
interval: '5m',
},
},
};
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview',
...options,
});
expect(response.status).to.be(200);
expect(response.body.errorCountChartPreview).to.eql([
{ x: 1627974600000, y: 4 },
{ x: 1627974900000, y: 2 },
{ x: 1627975200000, y: 0 },
]);
});
it('transaction_duration (with data)', async () => {
const options = getOptions();
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/transaction_duration/chart_preview',
});
expect(response.status).to.be(200);
expect(
response.body.latencyChartPreview.some(
(item: { name: string; data: Array<{ x: number; y: number | null }> }) =>
item.data.some((coordinate) => coordinate.x && coordinate.y)
)
).to.equal(true);
});
it('transaction_duration with transaction name', async () => {
const options = {
params: {
query: {
start,
end,
serviceName: 'opbeans-java',
transactionName: 'DispatcherServlet#doGet',
transactionType: 'request',
environment: 'ENVIRONMENT_ALL',
interval: '5m',
},
},
};
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/transaction_duration/chart_preview',
});
expect(response.status).to.be(200);
expect(response.body.latencyChartPreview[0].data[0]).to.eql({
x: 1627974600000,
y: 18485.85714285714,
});
});
it('transaction_duration with nonexistent transaction name', async () => {
const options = {
params: {
query: {
start,
end,
serviceName: 'opbeans-java',
transactionType: 'request',
transactionName: 'foo',
environment: 'ENVIRONMENT_ALL',
interval: '5m',
},
},
};
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/transaction_duration/chart_preview',
});
expect(response.status).to.be(200);
expect(response.body.latencyChartPreview).to.eql([]);
});
});
}

View file

@ -0,0 +1,73 @@
/*
* 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 '@kbn/apm-synthtrace-client';
import type { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace';
export const config = {
appleTransaction: {
name: 'GET /apple',
successRate: 75,
failureRate: 25,
},
bananaTransaction: {
name: 'GET /banana',
successRate: 50,
failureRate: 50,
},
};
export async function generateErrorData({
synthtraceEsClient,
serviceName,
start,
end,
}: {
synthtraceEsClient: ApmSynthtraceEsClient;
serviceName: string;
start: number;
end: number;
}) {
const serviceInstance = apm
.service({ name: serviceName, environment: 'production', agentName: 'go' })
.instance('instance-a');
const interval = '1m';
const { bananaTransaction, appleTransaction } = config;
const documents = [appleTransaction, bananaTransaction].flatMap((transaction, index) => {
return [
timerange(start, end)
.interval(interval)
.rate(transaction.successRate)
.generator((timestamp) =>
serviceInstance
.transaction({ transactionName: transaction.name })
.timestamp(timestamp)
.duration(10)
.success()
),
timerange(start, end)
.interval(interval)
.rate(transaction.failureRate)
.generator((timestamp) =>
serviceInstance
.transaction({ transactionName: transaction.name })
.errors(
serviceInstance
.error({ message: `Error ${index}`, type: transaction.name })
.timestamp(timestamp)
)
.duration(10)
.timestamp(timestamp)
.failure()
),
];
});
await synthtraceEsClient.index(documents);
}

View file

@ -0,0 +1,285 @@
/*
* 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 {
SERVICE_ENVIRONMENT,
SERVICE_NAME,
ERROR_GROUP_ID,
} from '@kbn/apm-plugin/common/es_fields/apm';
import type { PreviewChartResponseItem } from '@kbn/apm-plugin/server/routes/alerts/route';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { generateErrorData } from './generate_data';
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const synthtraceEsClient = getService('synthtraceEsClient');
const start = new Date('2021-01-01T00:00:00.000Z').getTime();
const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1;
const getOptions = () => ({
params: {
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
serviceName: 'synth-go',
environment: 'ENVIRONMENT_ALL',
interval: '5m',
},
},
});
registry.when(`without data loaded`, { config: 'basic', archives: [] }, () => {
it('error_count (without data)', async () => {
const options = getOptions();
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview',
...options,
});
expect(response.status).to.be(200);
expect(response.body.errorCountChartPreview.series).to.eql([]);
});
});
registry.when(`with data loaded`, { config: 'basic', archives: [] }, () => {
describe('error_count', () => {
before(async () => {
await generateErrorData({ serviceName: 'synth-go', start, end, synthtraceEsClient });
await generateErrorData({ serviceName: 'synth-java', start, end, synthtraceEsClient });
});
after(() => synthtraceEsClient.clean());
it('with data', async () => {
const options = getOptions();
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview',
...options,
});
expect(response.status).to.be(200);
expect(
response.body.errorCountChartPreview.series.some((item: PreviewChartResponseItem) =>
item.data.some((coordinate) => coordinate.x && coordinate.y)
)
).to.equal(true);
});
it('with error grouping key', async () => {
const options = {
params: {
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
serviceName: 'synth-go',
errorGroupingKey: '98b75903135eac35ad42419bd3b45cf8b4270c61cbd0ede0f7e8c8a9ac9fdb03',
environment: 'ENVIRONMENT_ALL',
interval: '5m',
},
},
};
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview',
...options,
});
expect(response.status).to.be(200);
expect(
response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({
name: item.name,
y: item.data[0].y,
}))
).to.eql([{ name: 'synth-go_production', y: 250 }]);
});
it('with no group by parameter', async () => {
const options = getOptions();
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview',
});
expect(response.status).to.be(200);
expect(response.body.errorCountChartPreview.series.length).to.equal(1);
expect(
response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({
name: item.name,
y: item.data[0].y,
}))
).to.eql([{ name: 'synth-go_production', y: 375 }]);
});
it('with default group by fields', async () => {
const options = {
params: {
query: {
...getOptions().params.query,
groupBy: [SERVICE_NAME, SERVICE_ENVIRONMENT],
},
},
};
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview',
});
expect(response.status).to.be(200);
expect(response.body.errorCountChartPreview.series.length).to.equal(1);
expect(
response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({
name: item.name,
y: item.data[0].y,
}))
).to.eql([{ name: 'synth-go_production', y: 375 }]);
});
it('with group by on error grouping key', async () => {
const options = {
params: {
query: {
...getOptions().params.query,
groupBy: [SERVICE_NAME, SERVICE_ENVIRONMENT, ERROR_GROUP_ID],
},
},
};
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview',
});
expect(response.status).to.be(200);
expect(response.body.errorCountChartPreview.series.length).to.equal(2);
expect(
response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({
name: item.name,
y: item.data[0].y,
}))
).to.eql([
{
name: 'synth-go_production_98b75903135eac35ad42419bd3b45cf8b4270c61cbd0ede0f7e8c8a9ac9fdb03',
y: 250,
},
{
name: 'synth-go_production_cf676a2665c3c548caaab78db6d23af63aed81bff4360a5b9873c07443aee78c',
y: 125,
},
]);
});
it('with group by on error grouping key and filter on error grouping key', async () => {
const options = {
params: {
query: {
...getOptions().params.query,
errorGroupingKey: 'cf676a2665c3c548caaab78db6d23af63aed81bff4360a5b9873c07443aee78c',
groupBy: [SERVICE_NAME, SERVICE_ENVIRONMENT, ERROR_GROUP_ID],
},
},
};
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview',
});
expect(response.status).to.be(200);
expect(response.body.errorCountChartPreview.series.length).to.equal(1);
expect(
response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({
name: item.name,
y: item.data[0].y,
}))
).to.eql([
{
name: 'synth-go_production_cf676a2665c3c548caaab78db6d23af63aed81bff4360a5b9873c07443aee78c',
y: 125,
},
]);
});
it('with empty service name', async () => {
const options = {
params: {
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
serviceName: '',
environment: 'ENVIRONMENT_ALL',
interval: '5m',
},
},
};
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview',
});
expect(response.status).to.be(200);
expect(
response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({
name: item.name,
y: item.data[0].y,
}))
).to.eql([
{ name: 'synth-go_production', y: 375 },
{ name: 'synth-java_production', y: 375 },
]);
});
it('with empty service name and group by on error grouping key', async () => {
const options = {
params: {
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
serviceName: '',
environment: 'ENVIRONMENT_ALL',
interval: '5m',
groupBy: [SERVICE_NAME, SERVICE_ENVIRONMENT, ERROR_GROUP_ID],
},
},
};
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview',
});
expect(response.status).to.be(200);
expect(
response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({
name: item.name,
y: item.data[0].y,
}))
).to.eql([
{
name: 'synth-go_production_98b75903135eac35ad42419bd3b45cf8b4270c61cbd0ede0f7e8c8a9ac9fdb03',
y: 250,
},
{
name: 'synth-java_production_98b75903135eac35ad42419bd3b45cf8b4270c61cbd0ede0f7e8c8a9ac9fdb03',
y: 250,
},
{
name: 'synth-go_production_cf676a2665c3c548caaab78db6d23af63aed81bff4360a5b9873c07443aee78c',
y: 125,
},
{
name: 'synth-java_production_cf676a2665c3c548caaab78db6d23af63aed81bff4360a5b9873c07443aee78c',
y: 125,
},
]);
});
});
});
}

View file

@ -0,0 +1,309 @@
/*
* 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 {
SERVICE_ENVIRONMENT,
SERVICE_NAME,
TRANSACTION_NAME,
TRANSACTION_TYPE,
} from '@kbn/apm-plugin/common/es_fields/apm';
import type { PreviewChartResponseItem } from '@kbn/apm-plugin/server/routes/alerts/route';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { generateErrorData } from './generate_data';
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const synthtraceEsClient = getService('synthtraceEsClient');
const start = new Date('2021-01-01T00:00:00.000Z').getTime();
const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1;
const getOptions = () => ({
params: {
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
serviceName: 'synth-go',
transactionType: 'request',
environment: 'ENVIRONMENT_ALL',
interval: '5m',
},
},
});
registry.when(`without data loaded`, { config: 'basic', archives: [] }, () => {
it('transaction_error_rate without data', async () => {
const options = getOptions();
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/rule_types/transaction_error_rate/chart_preview',
...options,
});
expect(response.status).to.be(200);
expect(response.body.errorRateChartPreview.series).to.eql([]);
});
});
registry.when(`with data loaded`, { config: 'basic', archives: [] }, () => {
describe('transaction_error_rate', () => {
before(async () => {
await generateErrorData({ serviceName: 'synth-go', start, end, synthtraceEsClient });
await generateErrorData({ serviceName: 'synth-java', start, end, synthtraceEsClient });
});
after(() => synthtraceEsClient.clean());
it('with data', async () => {
const options = getOptions();
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/rule_types/transaction_error_rate/chart_preview',
...options,
});
expect(response.status).to.be(200);
expect(
response.body.errorRateChartPreview.series.some((item: PreviewChartResponseItem) =>
item.data.some((coordinate) => coordinate.x && coordinate.y)
)
).to.equal(true);
});
it('with transaction name', async () => {
const options = {
params: {
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
serviceName: 'synth-go',
transactionName: 'GET /banana',
transactionType: 'request',
environment: 'ENVIRONMENT_ALL',
interval: '5m',
},
},
};
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/rule_types/transaction_error_rate/chart_preview',
...options,
});
expect(response.status).to.be(200);
expect(
response.body.errorRateChartPreview.series.map((item: PreviewChartResponseItem) => ({
name: item.name,
y: item.data[0].y,
}))
).to.eql([{ name: 'synth-go_production_request', y: 50 }]);
});
it('with nonexistent transaction name', async () => {
const options = {
params: {
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
serviceName: 'synth-go',
transactionName: 'foo',
transactionType: 'request',
environment: 'ENVIRONMENT_ALL',
interval: '5m',
},
},
};
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/rule_types/transaction_error_rate/chart_preview',
...options,
});
expect(response.status).to.be(200);
expect(response.body.errorRateChartPreview.series).to.eql([]);
});
it('with no group by parameter', async () => {
const options = getOptions();
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/transaction_error_rate/chart_preview',
});
expect(response.status).to.be(200);
expect(response.body.errorRateChartPreview.series.length).to.equal(1);
expect(
response.body.errorRateChartPreview.series.map((item: PreviewChartResponseItem) => ({
name: item.name,
y: item.data[0].y,
}))
).to.eql([{ name: 'synth-go_production_request', y: 37.5 }]);
});
it('with default group by fields', async () => {
const options = {
params: {
query: {
...getOptions().params.query,
groupBy: [SERVICE_NAME, SERVICE_ENVIRONMENT, TRANSACTION_TYPE],
},
},
};
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/transaction_error_rate/chart_preview',
});
expect(response.status).to.be(200);
expect(response.body.errorRateChartPreview.series.length).to.equal(1);
expect(
response.body.errorRateChartPreview.series.map((item: PreviewChartResponseItem) => ({
name: item.name,
y: item.data[0].y,
}))
).to.eql([{ name: 'synth-go_production_request', y: 37.5 }]);
});
it('with group by on transaction name', async () => {
const options = {
params: {
query: {
...getOptions().params.query,
groupBy: [SERVICE_NAME, SERVICE_ENVIRONMENT, TRANSACTION_TYPE, TRANSACTION_NAME],
},
},
};
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/transaction_error_rate/chart_preview',
});
expect(response.status).to.be(200);
expect(response.body.errorRateChartPreview.series.length).to.equal(2);
expect(
response.body.errorRateChartPreview.series.map((item: PreviewChartResponseItem) => ({
name: item.name,
y: item.data[0].y,
}))
).to.eql([
{
name: 'synth-go_production_request_GET /banana',
y: 50,
},
{
name: 'synth-go_production_request_GET /apple',
y: 25,
},
]);
});
it('with group by on transaction name and filter on transaction name', async () => {
const options = {
params: {
query: {
...getOptions().params.query,
transactionName: 'GET /apple',
groupBy: [SERVICE_NAME, SERVICE_ENVIRONMENT, TRANSACTION_TYPE, TRANSACTION_NAME],
},
},
};
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/transaction_error_rate/chart_preview',
});
expect(response.status).to.be(200);
expect(response.body.errorRateChartPreview.series.length).to.equal(1);
expect(
response.body.errorRateChartPreview.series.map((item: PreviewChartResponseItem) => ({
name: item.name,
y: item.data[0].y,
}))
).to.eql([{ name: 'synth-go_production_request_GET /apple', y: 25 }]);
});
it('with empty service name, transaction name and transaction type', async () => {
const options = {
params: {
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
serviceName: '',
transactionName: '',
transactionType: '',
environment: 'ENVIRONMENT_ALL',
interval: '5m',
},
},
};
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/transaction_error_rate/chart_preview',
});
expect(response.status).to.be(200);
expect(
response.body.errorRateChartPreview.series.map((item: PreviewChartResponseItem) => ({
name: item.name,
y: item.data[0].y,
}))
).to.eql([
{ name: 'synth-go_production_request', y: 37.5 },
{ name: 'synth-java_production_request', y: 37.5 },
]);
});
it('with empty service name, transaction name, transaction type and group by on transaction name', async () => {
const options = {
params: {
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
serviceName: '',
transactionName: '',
transactionType: '',
environment: 'ENVIRONMENT_ALL',
interval: '5m',
groupBy: [SERVICE_NAME, SERVICE_ENVIRONMENT, TRANSACTION_TYPE, TRANSACTION_NAME],
},
},
};
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/transaction_error_rate/chart_preview',
});
expect(response.status).to.be(200);
expect(
response.body.errorRateChartPreview.series.map((item: PreviewChartResponseItem) => ({
name: item.name,
y: item.data[0].y,
}))
).to.eql([
{
name: 'synth-go_production_request_GET /banana',
y: 50,
},
{
name: 'synth-java_production_request_GET /banana',
y: 50,
},
{
name: 'synth-go_production_request_GET /apple',
y: 25,
},
{
name: 'synth-java_production_request_GET /apple',
y: 25,
},
]);
});
});
});
}

View file

@ -0,0 +1,112 @@
/*
* 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 archives from '../../common/fixtures/es_archiver/archives_metadata';
import { FtrProviderContext } from '../../common/ftr_provider_context';
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const archiveName = 'apm_8.0.0';
const { end } = archives[archiveName];
const start = new Date(Date.parse(end) - 600000).toISOString();
const getOptions = () => ({
params: {
query: {
start,
end,
serviceName: 'opbeans-java',
transactionType: 'request' as string | undefined,
environment: 'ENVIRONMENT_ALL',
interval: '5m',
},
},
});
registry.when(`without data loaded`, { config: 'basic', archives: [] }, () => {
it('transaction_duration (without data)', async () => {
const options = getOptions();
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/rule_types/transaction_duration/chart_preview',
...options,
});
expect(response.status).to.be(200);
expect(response.body.latencyChartPreview).to.eql([]);
});
});
registry.when(`with data loaded`, { config: 'basic', archives: [archiveName] }, () => {
it('transaction_duration (with data)', async () => {
const options = getOptions();
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/transaction_duration/chart_preview',
});
expect(response.status).to.be(200);
expect(
response.body.latencyChartPreview.some(
(item: { name: string; data: Array<{ x: number; y: number | null }> }) =>
item.data.some((coordinate) => coordinate.x && coordinate.y)
)
).to.equal(true);
});
it('transaction_duration with transaction name', async () => {
const options = {
params: {
query: {
start,
end,
serviceName: 'opbeans-java',
transactionName: 'DispatcherServlet#doGet',
transactionType: 'request',
environment: 'ENVIRONMENT_ALL',
interval: '5m',
},
},
};
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/transaction_duration/chart_preview',
});
expect(response.status).to.be(200);
expect(response.body.latencyChartPreview[0].data[0]).to.eql({
x: 1627974600000,
y: 18485.85714285714,
});
});
it('transaction_duration with nonexistent transaction name', async () => {
const options = {
params: {
query: {
start,
end,
serviceName: 'opbeans-java',
transactionType: 'request',
transactionName: 'foo',
environment: 'ENVIRONMENT_ALL',
interval: '5m',
},
},
};
const response = await apmApiClient.readUser({
...options,
endpoint: 'GET /internal/apm/rule_types/transaction_duration/chart_preview',
});
expect(response.status).to.be(200);
expect(response.body.latencyChartPreview).to.eql([]);
});
});
}