[APM] Service maps: Add sparklines to the detail popover (#120021)

* adding error rate and latency timeseries

* adding sparklines

* fixing ui

* fixing spaces

* adjusting error color

* fixing api tests

* deleting unnecessary test

* changing loading spinner

* addressing pr comments

* fixing ci
This commit is contained in:
Cauê Marcondes 2021-12-02 13:55:50 -05:00 committed by GitHub
parent 392325ceae
commit 3360b6a53c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 627 additions and 480 deletions

View file

@ -7,6 +7,7 @@
import { i18n } from '@kbn/i18n';
import cytoscape from 'cytoscape';
import { Coordinate } from '../typings/timeseries';
import { ServiceAnomalyStats } from './anomaly_detection';
// These should be imported, but until TypeScript 4.2 we're inlining them here.
@ -59,13 +60,28 @@ export interface Connection {
}
export interface NodeStats {
avgMemoryUsage?: number | null;
avgCpuUsage?: number | null;
transactionStats: {
avgTransactionDuration: number | null;
avgRequestsPerMinute: number | null;
transactionStats?: {
latency?: {
value: number | null;
timeseries?: Coordinate[];
};
throughput?: {
value: number | null;
timeseries?: Coordinate[];
};
};
failedTransactionsRate?: {
value: number | null;
timeseries?: Coordinate[];
};
cpuUsage?: {
value?: number | null;
timeseries?: Coordinate[];
};
memoryUsage?: {
value?: number | null;
timeseries?: Coordinate[];
};
avgErrorRate: number | null;
}
export const invalidLicenseMessage = i18n.translate(

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiButton, EuiFlexItem } from '@elastic/eui';
import { EuiButton, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { TypeOf } from '@kbn/typed-react-router-config';
import { METRIC_TYPE } from '@kbn/analytics';
@ -73,6 +73,7 @@ export function BackendContents({
<EuiFlexItem>
<StatsList data={data} isLoading={isLoading} />
</EuiFlexItem>
<EuiSpacer size="s" />
<EuiFlexItem>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click*/}
<EuiButton

View file

@ -173,7 +173,7 @@ export function Popover({
<EuiFlexGroup
direction="column"
gutterSize="s"
style={{ width: popoverWidth }}
style={{ minWidth: popoverWidth }}
>
<EuiFlexItem>
<EuiTitle size="xxs">

View file

@ -7,7 +7,12 @@
/* eslint-disable @elastic/eui/href-or-on-click */
import { EuiButton, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui';
import {
EuiButton,
EuiFlexItem,
EuiHorizontalRule,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useApmParams } from '../../../../hooks/use_apm_params';
@ -89,6 +94,7 @@ export function ServiceContents({
)}
<StatsList data={data} isLoading={isLoading} />
</EuiFlexItem>
<EuiSpacer size="s" />
<EuiFlexItem>
<EuiButton href={detailsUrl} fill={true}>
{i18n.translate('xpack.apm.serviceMap.serviceDetailsButtonText', {

View file

@ -5,30 +5,23 @@
* 2.0.
*/
import { EuiFlexGroup, EuiLoadingSpinner, EuiText } from '@elastic/eui';
import {
EuiFlexGroup,
EuiFlexItem,
EuiLoadingChart,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isNumber } from 'lodash';
import React from 'react';
import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common';
import React, { useMemo } from 'react';
import { NodeStats } from '../../../../../common/service_map';
import {
asDuration,
asPercent,
asTransactionRate,
} from '../../../../../common/utils/formatters';
export const ItemRow = euiStyled.tr`
line-height: 2;
`;
export const ItemTitle = euiStyled.td`
color: ${({ theme }) => theme.eui.euiTextSubduedColor};
padding-right: 1rem;
`;
export const ItemDescription = euiStyled.td`
text-align: right;
`;
import { Coordinate } from '../../../../../typings/timeseries';
import { SparkPlot, Color } from '../../../shared/charts/spark_plot';
function LoadingSpinner() {
return (
@ -37,7 +30,7 @@ function LoadingSpinner() {
justifyContent="spaceAround"
style={{ height: 170 }}
>
<EuiLoadingSpinner size="xl" />
<EuiLoadingChart size="xl" />
</EuiFlexGroup>
);
}
@ -57,22 +50,82 @@ interface StatsListProps {
data: NodeStats;
}
interface Item {
title: string;
valueLabel: string | null;
timeseries?: Coordinate[];
color: Color;
}
export function StatsList({ data, isLoading }: StatsListProps) {
const {
avgCpuUsage,
avgErrorRate,
avgMemoryUsage,
transactionStats: { avgRequestsPerMinute, avgTransactionDuration },
} = data;
const { cpuUsage, failedTransactionsRate, memoryUsage, transactionStats } =
data;
const hasData = [
avgCpuUsage,
avgErrorRate,
avgMemoryUsage,
avgRequestsPerMinute,
avgTransactionDuration,
cpuUsage?.value,
failedTransactionsRate?.value,
memoryUsage?.value,
transactionStats?.throughput?.value,
transactionStats?.latency?.value,
].some((stat) => isNumber(stat));
const items: Item[] = useMemo(
() => [
{
title: i18n.translate(
'xpack.apm.serviceMap.avgTransDurationPopoverStat',
{
defaultMessage: 'Latency (avg.)',
}
),
valueLabel: isNumber(transactionStats?.latency?.value)
? asDuration(transactionStats?.latency?.value)
: null,
timeseries: transactionStats?.latency?.timeseries,
color: 'euiColorVis1',
},
{
title: i18n.translate(
'xpack.apm.serviceMap.avgReqPerMinutePopoverMetric',
{
defaultMessage: 'Throughput (avg.)',
}
),
valueLabel: asTransactionRate(transactionStats?.throughput?.value),
timeseries: transactionStats?.throughput?.timeseries,
color: 'euiColorVis0',
},
{
title: i18n.translate('xpack.apm.serviceMap.errorRatePopoverStat', {
defaultMessage: 'Failed transaction rate (avg.)',
}),
valueLabel: asPercent(failedTransactionsRate?.value, 1, ''),
timeseries: failedTransactionsRate?.timeseries,
color: 'euiColorVis7',
},
{
title: i18n.translate('xpack.apm.serviceMap.avgCpuUsagePopoverStat', {
defaultMessage: 'CPU usage (avg.)',
}),
valueLabel: asPercent(cpuUsage?.value, 1, ''),
timeseries: cpuUsage?.timeseries,
color: 'euiColorVis3',
},
{
title: i18n.translate(
'xpack.apm.serviceMap.avgMemoryUsagePopoverStat',
{
defaultMessage: 'Memory usage (avg.)',
}
),
valueLabel: asPercent(memoryUsage?.value, 1, ''),
timeseries: memoryUsage?.timeseries,
color: 'euiColorVis8',
},
],
[cpuUsage, failedTransactionsRate, memoryUsage, transactionStats]
);
if (isLoading) {
return <LoadingSpinner />;
}
@ -81,59 +134,40 @@ export function StatsList({ data, isLoading }: StatsListProps) {
return <NoDataMessage />;
}
const items = [
{
title: i18n.translate(
'xpack.apm.serviceMap.avgTransDurationPopoverStat',
{
defaultMessage: 'Latency (avg.)',
}
),
description: isNumber(avgTransactionDuration)
? asDuration(avgTransactionDuration)
: null,
},
{
title: i18n.translate(
'xpack.apm.serviceMap.avgReqPerMinutePopoverMetric',
{
defaultMessage: 'Throughput (avg.)',
}
),
description: asTransactionRate(avgRequestsPerMinute),
},
{
title: i18n.translate('xpack.apm.serviceMap.errorRatePopoverStat', {
defaultMessage: 'Failed transaction rate (avg.)',
}),
description: asPercent(avgErrorRate, 1, ''),
},
{
title: i18n.translate('xpack.apm.serviceMap.avgCpuUsagePopoverStat', {
defaultMessage: 'CPU usage (avg.)',
}),
description: asPercent(avgCpuUsage, 1, ''),
},
{
title: i18n.translate('xpack.apm.serviceMap.avgMemoryUsagePopoverStat', {
defaultMessage: 'Memory usage (avg.)',
}),
description: asPercent(avgMemoryUsage, 1, ''),
},
];
return (
<table>
<tbody>
{items.map(({ title, description }) => {
return description ? (
<ItemRow key={title}>
<ItemTitle>{title}</ItemTitle>
<ItemDescription>{description}</ItemDescription>
</ItemRow>
) : null;
})}
</tbody>
</table>
<EuiFlexGroup direction="column" responsive={false} gutterSize="m">
{items.map(({ title, valueLabel, timeseries, color }) => {
if (!valueLabel) {
return null;
}
return (
<EuiFlexItem key={title}>
<EuiFlexGroup gutterSize="none" responsive={false}>
<EuiFlexItem
style={{
display: 'flex',
justifyContent: 'end',
}}
>
<EuiText color="subdued" size="s">
{title}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{timeseries ? (
<SparkPlot
series={timeseries}
color={color}
valueLabel={valueLabel}
/>
) : (
<div>{valueLabel}</div>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
);
}

View file

@ -20,7 +20,7 @@ import {
import { FETCH_STATUS } from '../../../hooks/use_fetcher';
import { iconForNode } from './icons';
export const popoverWidth = 280;
export const popoverWidth = 350;
function getServiceAnomalyStats(el: cytoscape.NodeSingular) {
const serviceAnomalyStats: ServiceAnomalyStats | undefined = el.data(

View file

@ -82,7 +82,7 @@ export async function getSearchAggregatedTransactions({
}
}
export function getTransactionDurationFieldForTransactions(
export function getDurationFieldForTransactions(
searchAggregatedTransactions: boolean
) {
return searchAggregatedTransactions

View file

@ -42,6 +42,7 @@ export async function getErrorRate({
searchAggregatedTransactions,
start,
end,
numBuckets,
}: {
environment: string;
kuery: string;
@ -52,6 +53,7 @@ export async function getErrorRate({
searchAggregatedTransactions: boolean;
start: number;
end: number;
numBuckets?: number;
}): Promise<{
timeseries: Coordinate[];
average: number | null;
@ -91,6 +93,7 @@ export async function getErrorRate({
start,
end,
searchAggregatedTransactions,
numBuckets,
}).intervalString,
min_doc_count: 0,
extended_bounds: { min: start, max: end },

View file

@ -13,7 +13,7 @@ import {
} from '../../../common/elasticsearch_fieldnames';
import { arrayUnionToCallable } from '../../../common/utils/array_union_to_callable';
import { TransactionGroupRequestBase, TransactionGroupSetup } from './fetcher';
import { getTransactionDurationFieldForTransactions } from '../helpers/transactions';
import { getDurationFieldForTransactions } from '../helpers/transactions';
import { AgentName } from '../../../typings/es_schemas/ui/fields/agent';
interface MetricParams {
request: TransactionGroupRequestBase;
@ -49,9 +49,7 @@ export async function getAverages({
const params = mergeRequestWithAggs(request, {
avg: {
avg: {
field: getTransactionDurationFieldForTransactions(
searchAggregatedTransactions
),
field: getDurationFieldForTransactions(searchAggregatedTransactions),
},
},
});
@ -119,9 +117,7 @@ export async function getSums({
const params = mergeRequestWithAggs(request, {
sum: {
sum: {
field: getTransactionDurationFieldForTransactions(
searchAggregatedTransactions
),
field: getDurationFieldForTransactions(searchAggregatedTransactions),
},
},
});

View file

@ -16,7 +16,7 @@ import { AlertParams } from '../route';
import {
getSearchAggregatedTransactions,
getDocumentTypeFilterForTransactions,
getTransactionDurationFieldForTransactions,
getDurationFieldForTransactions,
getProcessorEventForTransactions,
} from '../../../lib/helpers/transactions';
import { Setup } from '../../../lib/helpers/setup_request';
@ -55,7 +55,7 @@ export async function getTransactionDurationChartPreview({
},
};
const transactionDurationField = getTransactionDurationFieldForTransactions(
const transactionDurationField = getDurationFieldForTransactions(
searchAggregatedTransactions
);

View file

@ -36,7 +36,7 @@ import { environmentQuery } from '../../../common/utils/environment_query';
import { getDurationFormatter } from '../../../common/utils/formatters';
import {
getDocumentTypeFilterForTransactions,
getTransactionDurationFieldForTransactions,
getDurationFieldForTransactions,
} from '../../lib/helpers/transactions';
import { getApmIndices } from '../../routes/settings/apm_indices/get_apm_indices';
import { apmActionVariables } from './action_variables';
@ -110,7 +110,7 @@ export function registerTransactionDurationAlertType({
? indices.metric
: indices.transaction;
const field = getTransactionDurationFieldForTransactions(
const field = getDurationFieldForTransactions(
searchAggregatedTransactions
);

View file

@ -16,8 +16,11 @@ import { EventOutcome } from '../../../common/event_outcome';
import { ProcessorEvent } from '../../../common/processor_event';
import { environmentQuery } from '../../../common/utils/environment_query';
import { withApmSpan } from '../../utils/with_apm_span';
import { calculateThroughput } from '../../lib/helpers/calculate_throughput';
import { calculateThroughputWithRange } from '../../lib/helpers/calculate_throughput';
import { Setup } from '../../lib/helpers/setup_request';
import { getBucketSize } from '../../lib/helpers/get_bucket_size';
import { getFailedTransactionRateTimeSeries } from '../../lib/helpers/transaction_error_rate';
import { NodeStats } from '../../../common/service_map';
interface Options {
setup: Setup;
@ -33,10 +36,24 @@ export function getServiceMapBackendNodeInfo({
setup,
start,
end,
}: Options) {
}: Options): Promise<NodeStats> {
return withApmSpan('get_service_map_backend_node_stats', async () => {
const { apmEventClient } = setup;
const { intervalString } = getBucketSize({ start, end, numBuckets: 20 });
const subAggs = {
latency_sum: {
sum: { field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM },
},
count: {
sum: { field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT },
},
outcomes: {
terms: { field: EVENT_OUTCOME, include: [EventOutcome.failure] },
},
};
const response = await apmEventClient.search(
'get_service_map_backend_node_stats',
{
@ -55,18 +72,15 @@ export function getServiceMapBackendNodeInfo({
},
},
aggs: {
latency_sum: {
sum: {
field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM,
...subAggs,
timeseries: {
date_histogram: {
field: '@timestamp',
fixed_interval: intervalString,
min_doc_count: 0,
extended_bounds: { min: start, max: end },
},
},
count: {
sum: {
field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT,
},
},
[EVENT_OUTCOME]: {
terms: { field: EVENT_OUTCOME, include: [EventOutcome.failure] },
aggs: subAggs,
},
},
},
@ -74,13 +88,13 @@ export function getServiceMapBackendNodeInfo({
);
const count = response.aggregations?.count.value ?? 0;
const errorCount =
response.aggregations?.[EVENT_OUTCOME].buckets[0]?.doc_count ?? 0;
const failedTransactionsRateCount =
response.aggregations?.outcomes.buckets[0]?.doc_count ?? 0;
const latencySum = response.aggregations?.latency_sum.value ?? 0;
const avgErrorRate = errorCount / count;
const avgTransactionDuration = latencySum / count;
const avgRequestsPerMinute = calculateThroughput({
const avgFailedTransactionsRate = failedTransactionsRateCount / count;
const latency = latencySum / count;
const throughput = calculateThroughputWithRange({
start,
end,
value: count,
@ -88,19 +102,48 @@ export function getServiceMapBackendNodeInfo({
if (count === 0) {
return {
avgErrorRate: null,
failedTransactionsRate: undefined,
transactionStats: {
avgRequestsPerMinute: null,
avgTransactionDuration: null,
throughput: undefined,
latency: undefined,
},
};
}
return {
avgErrorRate,
failedTransactionsRate: {
value: avgFailedTransactionsRate,
timeseries: response.aggregations?.timeseries
? getFailedTransactionRateTimeSeries(
response.aggregations.timeseries.buckets
)
: undefined,
},
transactionStats: {
avgRequestsPerMinute,
avgTransactionDuration,
throughput: {
value: throughput,
timeseries: response.aggregations?.timeseries.buckets.map(
(bucket) => {
return {
x: bucket.key,
y: calculateThroughputWithRange({
start,
end,
value: bucket.doc_count ?? 0,
}),
};
}
),
},
latency: {
value: latency,
timeseries: response.aggregations?.timeseries.buckets.map(
(bucket) => ({
x: bucket.key,
y: bucket.latency_sum.value,
})
),
},
},
};
});

View file

@ -1,96 +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 { getServiceMapServiceNodeInfo } from './get_service_map_service_node_info';
import { Setup } from '../../lib/helpers/setup_request';
import * as getErrorRateModule from '../../lib/transaction_groups/get_error_rate';
import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values';
describe('getServiceMapServiceNodeInfo', () => {
describe('with no results', () => {
it('returns null data', async () => {
const setup = {
apmEventClient: {
search: () =>
Promise.resolve({
hits: { total: { value: 0 } },
}),
},
indices: {},
uiFilters: {},
} as unknown as Setup;
const serviceName = 'test service name';
const result = await getServiceMapServiceNodeInfo({
environment: 'test environment',
setup,
serviceName,
searchAggregatedTransactions: false,
start: 1528113600000,
end: 1528977600000,
});
expect(result).toEqual({
avgCpuUsage: null,
avgErrorRate: null,
avgMemoryUsage: null,
transactionStats: {
avgRequestsPerMinute: null,
avgTransactionDuration: null,
},
});
});
});
describe('with some results', () => {
it('returns data', async () => {
jest.spyOn(getErrorRateModule, 'getErrorRate').mockResolvedValueOnce({
average: 0.5,
timeseries: [{ x: 1634808240000, y: 0 }],
});
const setup = {
apmEventClient: {
search: () =>
Promise.resolve({
hits: {
total: { value: 1 },
},
aggregations: {
duration: { value: null },
avgCpuUsage: { value: null },
avgMemoryUsage: { value: null },
},
}),
},
indices: {},
start: 1593460053026000,
end: 1593497863217000,
config: { metricsInterval: 30 },
uiFilters: { environment: 'test environment' },
} as unknown as Setup;
const serviceName = 'test service name';
const result = await getServiceMapServiceNodeInfo({
setup,
serviceName,
searchAggregatedTransactions: false,
environment: ENVIRONMENT_ALL.value,
start: 1593460053026000,
end: 1593497863217000,
});
expect(result).toEqual({
avgCpuUsage: null,
avgErrorRate: 0.5,
avgMemoryUsage: null,
transactionStats: {
avgRequestsPerMinute: 0.000001586873761097901,
avgTransactionDuration: null,
},
});
});
});
});

View file

@ -6,6 +6,7 @@
*/
import { ESFilter } from '../../../../../../src/core/types/elasticsearch';
import { rangeQuery } from '../../../../observability/server';
import {
METRIC_CGROUP_MEMORY_USAGE_BYTES,
METRIC_SYSTEM_CPU_PERCENT,
@ -15,24 +16,25 @@ import {
TRANSACTION_TYPE,
} from '../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../common/processor_event';
import { NodeStats } from '../../../common/service_map';
import {
TRANSACTION_PAGE_LOAD,
TRANSACTION_REQUEST,
} from '../../../common/transaction_types';
import { rangeQuery } from '../../../../observability/server';
import { environmentQuery } from '../../../common/utils/environment_query';
import { withApmSpan } from '../../utils/with_apm_span';
import { getBucketSizeForAggregatedTransactions } from '../../lib/helpers/get_bucket_size_for_aggregated_transactions';
import { Setup } from '../../lib/helpers/setup_request';
import {
getDocumentTypeFilterForTransactions,
getTransactionDurationFieldForTransactions,
getDurationFieldForTransactions,
getProcessorEventForTransactions,
} from '../../lib/helpers/transactions';
import { Setup } from '../../lib/helpers/setup_request';
import { getErrorRate } from '../../lib/transaction_groups/get_error_rate';
import { withApmSpan } from '../../utils/with_apm_span';
import {
percentCgroupMemoryUsedScript,
percentSystemMemoryUsedScript,
} from '../metrics/by_agent/shared/memory';
import { getErrorRate } from '../../lib/transaction_groups/get_error_rate';
interface Options {
setup: Setup;
@ -48,8 +50,13 @@ interface TaskParameters {
filter: ESFilter[];
searchAggregatedTransactions: boolean;
minutes: number;
serviceName?: string;
serviceName: string;
setup: Setup;
start: number;
end: number;
intervalString: string;
bucketSize: number;
numBuckets: number;
}
export function getServiceMapServiceNodeInfo({
@ -59,7 +66,7 @@ export function getServiceMapServiceNodeInfo({
searchAggregatedTransactions,
start,
end,
}: Options) {
}: Options): Promise<NodeStats> {
return withApmSpan('get_service_map_node_stats', async () => {
const filter: ESFilter[] = [
{ term: { [SERVICE_NAME]: serviceName } },
@ -68,6 +75,14 @@ export function getServiceMapServiceNodeInfo({
];
const minutes = Math.abs((end - start) / (1000 * 60));
const numBuckets = 20;
const { intervalString, bucketSize } =
getBucketSizeForAggregatedTransactions({
start,
end,
searchAggregatedTransactions,
numBuckets,
});
const taskParams = {
environment,
filter,
@ -77,34 +92,38 @@ export function getServiceMapServiceNodeInfo({
setup,
start,
end,
intervalString,
bucketSize,
numBuckets,
};
const [errorStats, transactionStats, cpuStats, memoryStats] =
const [failedTransactionsRate, transactionStats, cpuUsage, memoryUsage] =
await Promise.all([
getErrorStats(taskParams),
getFailedTransactionsRateStats(taskParams),
getTransactionStats(taskParams),
getCpuStats(taskParams),
getMemoryStats(taskParams),
]);
return {
...errorStats,
failedTransactionsRate,
transactionStats,
...cpuStats,
...memoryStats,
cpuUsage,
memoryUsage,
};
});
}
async function getErrorStats({
async function getFailedTransactionsRateStats({
setup,
serviceName,
environment,
searchAggregatedTransactions,
start,
end,
}: Options) {
numBuckets,
}: TaskParameters): Promise<NodeStats['failedTransactionsRate']> {
return withApmSpan('get_error_rate_for_service_map_node', async () => {
const { average } = await getErrorRate({
const { average, timeseries } = await getErrorRate({
environment,
setup,
serviceName,
@ -112,8 +131,9 @@ async function getErrorStats({
start,
end,
kuery: '',
numBuckets,
});
return { avgErrorRate: average };
return { value: average, timeseries };
});
}
@ -122,12 +142,16 @@ async function getTransactionStats({
filter,
minutes,
searchAggregatedTransactions,
}: TaskParameters): Promise<{
avgTransactionDuration: number | null;
avgRequestsPerMinute: number | null;
}> {
start,
end,
intervalString,
}: TaskParameters): Promise<NodeStats['transactionStats']> {
const { apmEventClient } = setup;
const durationField = getDurationFieldForTransactions(
searchAggregatedTransactions
);
const params = {
apm: {
events: [getProcessorEventForTransactions(searchAggregatedTransactions)],
@ -154,11 +178,16 @@ async function getTransactionStats({
},
track_total_hits: true,
aggs: {
duration: {
avg: {
field: getTransactionDurationFieldForTransactions(
searchAggregatedTransactions
),
duration: { avg: { field: durationField } },
timeseries: {
date_histogram: {
field: '@timestamp',
fixed_interval: intervalString,
min_doc_count: 0,
extended_bounds: { min: start, max: end },
},
aggs: {
latency: { avg: { field: durationField } },
},
},
},
@ -172,15 +201,32 @@ async function getTransactionStats({
const totalRequests = response.hits.total.value;
return {
avgTransactionDuration: response.aggregations?.duration.value ?? null,
avgRequestsPerMinute: totalRequests > 0 ? totalRequests / minutes : null,
latency: {
value: response.aggregations?.duration.value ?? null,
timeseries: response.aggregations?.timeseries.buckets.map((bucket) => ({
x: bucket.key,
y: bucket.latency.value,
})),
},
throughput: {
value: totalRequests > 0 ? totalRequests / minutes : null,
timeseries: response.aggregations?.timeseries.buckets.map((bucket) => {
return {
x: bucket.key,
y: bucket.doc_count ?? 0,
};
}),
},
};
}
async function getCpuStats({
setup,
filter,
}: TaskParameters): Promise<{ avgCpuUsage: number | null }> {
intervalString,
start,
end,
}: TaskParameters): Promise<NodeStats['cpuUsage']> {
const { apmEventClient } = setup;
const response = await apmEventClient.search(
@ -199,22 +245,44 @@ async function getCpuStats({
],
},
},
aggs: { avgCpuUsage: { avg: { field: METRIC_SYSTEM_CPU_PERCENT } } },
aggs: {
avgCpuUsage: { avg: { field: METRIC_SYSTEM_CPU_PERCENT } },
timeseries: {
date_histogram: {
field: '@timestamp',
fixed_interval: intervalString,
min_doc_count: 0,
extended_bounds: { min: start, max: end },
},
aggs: {
cpuAvg: { avg: { field: METRIC_SYSTEM_CPU_PERCENT } },
},
},
},
},
}
);
return { avgCpuUsage: response.aggregations?.avgCpuUsage.value ?? null };
return {
value: response.aggregations?.avgCpuUsage.value ?? null,
timeseries: response.aggregations?.timeseries.buckets.map((bucket) => ({
x: bucket.key,
y: bucket.cpuAvg.value,
})),
};
}
function getMemoryStats({
setup,
filter,
}: TaskParameters): Promise<{ avgMemoryUsage: number | null }> {
intervalString,
start,
end,
}: TaskParameters) {
return withApmSpan('get_memory_stats_for_service_map_node', async () => {
const { apmEventClient } = setup;
const getAvgMemoryUsage = async ({
const getMemoryUsage = async ({
additionalFilters,
script,
}: {
@ -222,7 +290,7 @@ function getMemoryStats({
script:
| typeof percentCgroupMemoryUsedScript
| typeof percentSystemMemoryUsedScript;
}) => {
}): Promise<NodeStats['memoryUsage']> => {
const response = await apmEventClient.search(
'get_avg_memory_for_service_map_node',
{
@ -238,22 +306,39 @@ function getMemoryStats({
},
aggs: {
avgMemoryUsage: { avg: { script } },
timeseries: {
date_histogram: {
field: '@timestamp',
fixed_interval: intervalString,
min_doc_count: 0,
extended_bounds: { min: start, max: end },
},
aggs: {
memoryAvg: { avg: { script } },
},
},
},
},
}
);
return response.aggregations?.avgMemoryUsage.value ?? null;
return {
value: response.aggregations?.avgMemoryUsage.value ?? null,
timeseries: response.aggregations?.timeseries.buckets.map((bucket) => ({
x: bucket.key,
y: bucket.memoryAvg.value,
})),
};
};
let avgMemoryUsage = await getAvgMemoryUsage({
let memoryUsage = await getMemoryUsage({
additionalFilters: [
{ exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES } },
],
script: percentCgroupMemoryUsedScript,
});
if (!avgMemoryUsage) {
avgMemoryUsage = await getAvgMemoryUsage({
if (!memoryUsage) {
memoryUsage = await getMemoryUsage({
additionalFilters: [
{ exists: { field: METRIC_SYSTEM_FREE_MEMORY } },
{ exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } },
@ -262,6 +347,6 @@ function getMemoryStats({
});
}
return { avgMemoryUsage };
return memoryUsage;
});
}

View file

@ -18,7 +18,7 @@ import { kqlQuery, rangeQuery } from '../../../../../observability/server';
import { environmentQuery } from '../../../../common/utils/environment_query';
import {
getDocumentTypeFilterForTransactions,
getTransactionDurationFieldForTransactions,
getDurationFieldForTransactions,
getProcessorEventForTransactions,
} from '../../../lib/helpers/transactions';
import { calculateThroughput } from '../../../lib/helpers/calculate_throughput';
@ -89,9 +89,7 @@ export async function getServiceInstancesTransactionStatistics<
}
);
const field = getTransactionDurationFieldForTransactions(
searchAggregatedTransactions
);
const field = getDurationFieldForTransactions(searchAggregatedTransactions);
const subAggs = {
...getLatencyAggregation(latencyAggregationType, field),

View file

@ -20,7 +20,7 @@ import { environmentQuery } from '../../../common/utils/environment_query';
import { Coordinate } from '../../../typings/timeseries';
import {
getDocumentTypeFilterForTransactions,
getTransactionDurationFieldForTransactions,
getDurationFieldForTransactions,
getProcessorEventForTransactions,
} from '../../lib/helpers/transactions';
import { getBucketSizeForAggregatedTransactions } from '../../lib/helpers/get_bucket_size_for_aggregated_transactions';
@ -72,9 +72,7 @@ export async function getServiceTransactionGroupDetailedStatistics({
searchAggregatedTransactions,
});
const field = getTransactionDurationFieldForTransactions(
searchAggregatedTransactions
);
const field = getDurationFieldForTransactions(searchAggregatedTransactions);
const response = await apmEventClient.search(
'get_service_transaction_group_detailed_statistics',

View file

@ -17,7 +17,7 @@ import { rangeQuery, kqlQuery } from '../../../../observability/server';
import { environmentQuery } from '../../../common/utils/environment_query';
import {
getDocumentTypeFilterForTransactions,
getTransactionDurationFieldForTransactions,
getDurationFieldForTransactions,
getProcessorEventForTransactions,
} from '../../lib/helpers/transactions';
import { calculateThroughput } from '../../lib/helpers/calculate_throughput';
@ -59,9 +59,7 @@ export async function getServiceTransactionGroups({
const { apmEventClient, config } = setup;
const bucketSize = config.ui.transactionGroupBucketSize;
const field = getTransactionDurationFieldForTransactions(
searchAggregatedTransactions
);
const field = getDurationFieldForTransactions(searchAggregatedTransactions);
const response = await apmEventClient.search(
'get_service_transaction_groups',

View file

@ -20,7 +20,7 @@ import { environmentQuery } from '../../../../common/utils/environment_query';
import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent';
import {
getDocumentTypeFilterForTransactions,
getTransactionDurationFieldForTransactions,
getDurationFieldForTransactions,
getProcessorEventForTransactions,
} from '../../../lib/helpers/transactions';
import { calculateThroughput } from '../../../lib/helpers/calculate_throughput';
@ -56,9 +56,7 @@ export async function getServiceTransactionStats({
const metrics = {
avg_duration: {
avg: {
field: getTransactionDurationFieldForTransactions(
searchAggregatedTransactions
),
field: getDurationFieldForTransactions(searchAggregatedTransactions),
},
},
outcomes,

View file

@ -19,7 +19,7 @@ import { environmentQuery } from '../../../../common/utils/environment_query';
import { getOffsetInMs } from '../../../../common/utils/get_offset_in_ms';
import {
getDocumentTypeFilterForTransactions,
getTransactionDurationFieldForTransactions,
getDurationFieldForTransactions,
getProcessorEventForTransactions,
} from '../../../lib/helpers/transactions';
import { calculateThroughput } from '../../../lib/helpers/calculate_throughput';
@ -61,9 +61,7 @@ export async function getServiceTransactionDetailedStatistics({
const metrics = {
avg_duration: {
avg: {
field: getTransactionDurationFieldForTransactions(
searchAggregatedTransactions
),
field: getDurationFieldForTransactions(searchAggregatedTransactions),
},
},
outcomes,

View file

@ -21,7 +21,7 @@ import {
import { environmentQuery } from '../../../../common/utils/environment_query';
import {
getDocumentTypeFilterForTransactions,
getTransactionDurationFieldForTransactions,
getDurationFieldForTransactions,
getProcessorEventForTransactions,
} from '../../../lib/helpers/transactions';
import { Setup } from '../../../lib/helpers/setup_request';
@ -64,7 +64,7 @@ function searchLatency({
searchAggregatedTransactions,
});
const transactionDurationField = getTransactionDurationFieldForTransactions(
const transactionDurationField = getDurationFieldForTransactions(
searchAggregatedTransactions
);

View file

@ -5,46 +5,63 @@
* 2.0.
*/
import querystring from 'querystring';
import url from 'url';
import expect from '@kbn/expect';
import { isEmpty, orderBy, uniq } from 'lodash';
import { ServiceConnectionNode } from '../../../../plugins/apm/common/service_map';
import { ApmApiError, SupertestReturnType } from '../../common/apm_api_supertest';
import archives_metadata from '../../common/fixtures/es_archiver/archives_metadata';
import { PromiseReturnType } from '../../../../plugins/observability/typings/common';
import { FtrProviderContext } from '../../common/ftr_provider_context';
type BackendResponse = SupertestReturnType<'GET /internal/apm/service-map/backend'>;
type ServiceNodeResponse =
SupertestReturnType<'GET /internal/apm/service-map/service/{serviceName}'>;
type ServiceMapResponse = SupertestReturnType<'GET /internal/apm/service-map'>;
export default function serviceMapsApiTests({ getService }: FtrProviderContext) {
const apmApiClient = getService('apmApiClient');
const registry = getService('registry');
const supertest = getService('legacySupertestAsApmReadUser');
const supertestAsApmReadUserWithoutMlAccess = getService(
'legacySupertestAsApmReadUserWithoutMlAccess'
);
const archiveName = 'apm_8.0.0';
const metadata = archives_metadata[archiveName];
const start = encodeURIComponent(metadata.start);
const end = encodeURIComponent(metadata.end);
registry.when('Service map with a basic license', { config: 'basic', archives: [] }, () => {
it('is only be available to users with Platinum license (or higher)', async () => {
const response = await supertest.get(
`/internal/apm/service-map?start=${start}&end=${end}&environment=ENVIRONMENT_ALL`
);
try {
await apmApiClient.readUser({
endpoint: `GET /internal/apm/service-map`,
params: {
query: {
start: metadata.start,
end: metadata.end,
environment: 'ENVIRONMENT_ALL',
},
},
});
expect(response.status).to.be(403);
expectSnapshot(response.body.message).toMatchInline(
`"In order to access Service Maps, you must be subscribed to an Elastic Platinum license. With it, you'll have the ability to visualize your entire application stack along with your APM data."`
);
expect(true).to.be(false);
} catch (e) {
const err = e as ApmApiError;
expect(err.res.status).to.be(403);
expectSnapshot(err.res.body.message).toMatchInline(
`"In order to access Service Maps, you must be subscribed to an Elastic Platinum license. With it, you'll have the ability to visualize your entire application stack along with your APM data."`
);
}
});
});
registry.when('Service map without data', { config: 'trial', archives: [] }, () => {
describe('/internal/apm/service-map', () => {
it('returns an empty list', async () => {
const response = await supertest.get(
`/internal/apm/service-map?start=${start}&end=${end}&environment=ENVIRONMENT_ALL`
);
const response = await apmApiClient.readUser({
endpoint: `GET /internal/apm/service-map`,
params: {
query: {
start: metadata.start,
end: metadata.end,
environment: 'ENVIRONMENT_ALL',
},
},
});
expect(response.status).to.be(200);
expect(response.body.elements.length).to.be(0);
@ -52,63 +69,78 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext)
});
describe('/internal/apm/service-map/service/{serviceName}', () => {
it('returns an object with nulls', async () => {
const q = querystring.stringify({
start: metadata.start,
end: metadata.end,
environment: 'ENVIRONMENT_ALL',
});
const response = await supertest.get(`/internal/apm/service-map/service/opbeans-node?${q}`);
expect(response.status).to.be(200);
expectSnapshot(response.body).toMatchInline(`
Object {
"avgCpuUsage": null,
"avgErrorRate": null,
"avgMemoryUsage": null,
"transactionStats": Object {
"avgRequestsPerMinute": null,
"avgTransactionDuration": null,
let response: ServiceNodeResponse;
before(async () => {
response = await apmApiClient.readUser({
endpoint: `GET /internal/apm/service-map/service/{serviceName}`,
params: {
path: { serviceName: 'opbeans-node' },
query: {
start: metadata.start,
end: metadata.end,
environment: 'ENVIRONMENT_ALL',
},
}
`);
},
});
});
it('retuns status code 200', () => {
expect(response.status).to.be(200);
});
it('returns an object with nulls', async () => {
[
response.body.failedTransactionsRate?.value,
response.body.memoryUsage?.value,
response.body.cpuUsage?.value,
response.body.transactionStats?.latency?.value,
response.body.transactionStats?.throughput?.value,
].forEach((value) => {
expect(value).to.be.eql(null);
});
});
});
describe('/internal/apm/service-map/backend', () => {
it('returns an object with nulls', async () => {
const q = querystring.stringify({
backendName: 'postgres',
start: metadata.start,
end: metadata.end,
environment: 'ENVIRONMENT_ALL',
});
const response = await supertest.get(`/internal/apm/service-map/backend?${q}`);
expect(response.status).to.be(200);
expectSnapshot(response.body).toMatchInline(`
Object {
"avgErrorRate": null,
"transactionStats": Object {
"avgRequestsPerMinute": null,
"avgTransactionDuration": null,
let response: BackendResponse;
before(async () => {
response = await apmApiClient.readUser({
endpoint: `GET /internal/apm/service-map/backend`,
params: {
query: {
backendName: 'postgres',
start: metadata.start,
end: metadata.end,
environment: 'ENVIRONMENT_ALL',
},
}
`);
},
});
});
it('retuns status code 200', () => {
expect(response.status).to.be(200);
});
it('returns undefined values', () => {
expect(response.body).to.eql({ transactionStats: {} });
});
});
});
registry.when('Service Map with data', { config: 'trial', archives: ['apm_8.0.0'] }, () => {
describe('/internal/apm/service-map', () => {
let response: PromiseReturnType<typeof supertest.get>;
let response: ServiceMapResponse;
before(async () => {
response = await supertest.get(
`/internal/apm/service-map?start=${start}&end=${end}&environment=ENVIRONMENT_ALL`
);
response = await apmApiClient.readUser({
endpoint: `GET /internal/apm/service-map`,
params: {
query: {
environment: 'ENVIRONMENT_ALL',
start: metadata.start,
end: metadata.end,
},
},
});
});
it('returns service map elements', () => {
@ -126,17 +158,17 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext)
).sort();
expectSnapshot(serviceNames).toMatchInline(`
Array [
"auditbeat",
"opbeans-dotnet",
"opbeans-go",
"opbeans-java",
"opbeans-node",
"opbeans-python",
"opbeans-ruby",
"opbeans-rum",
]
`);
Array [
"auditbeat",
"opbeans-dotnet",
"opbeans-go",
"opbeans-java",
"opbeans-node",
"opbeans-python",
"opbeans-ruby",
"opbeans-rum",
]
`);
const externalDestinations = uniq(
elements
@ -145,115 +177,119 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext)
).sort();
expectSnapshot(externalDestinations).toMatchInline(`
Array [
">elasticsearch",
">postgresql",
">redis",
">sqlite",
]
`);
Array [
">elasticsearch",
">postgresql",
">redis",
">sqlite",
]
`);
});
describe('with ML data', () => {
describe('with the default apm user', () => {
before(async () => {
response = await supertest.get(
`/internal/apm/service-map?start=${start}&end=${end}&environment=ENVIRONMENT_ALL`
);
response = await apmApiClient.readUser({
endpoint: `GET /internal/apm/service-map`,
params: {
query: {
environment: 'ENVIRONMENT_ALL',
start: metadata.start,
end: metadata.end,
},
},
});
});
it('returns service map elements with anomaly stats', () => {
expect(response.status).to.be(200);
const dataWithAnomalies = response.body.elements.filter(
(el: { data: { serviceAnomalyStats?: {} } }) => !isEmpty(el.data.serviceAnomalyStats)
(el) => !isEmpty((el.data as ServiceConnectionNode).serviceAnomalyStats)
);
expect(dataWithAnomalies).not.to.be.empty();
dataWithAnomalies.forEach(({ data }: any) => {
expect(
Object.values(data.serviceAnomalyStats).filter((value) => isEmpty(value))
).to.not.empty();
});
});
it('returns the correct anomaly stats', () => {
const dataWithAnomalies = response.body.elements.filter(
(el: { data: { serviceAnomalyStats?: {} } }) => !isEmpty(el.data.serviceAnomalyStats)
(el) => !isEmpty((el.data as ServiceConnectionNode).serviceAnomalyStats)
);
expect(dataWithAnomalies).not.to.be.empty();
expectSnapshot(dataWithAnomalies.length).toMatchInline(`7`);
expectSnapshot(orderBy(dataWithAnomalies, 'data.id').slice(0, 3)).toMatchInline(`
Array [
Object {
"data": Object {
"agent.name": "dotnet",
"id": "opbeans-dotnet",
"service.environment": "production",
"service.name": "opbeans-dotnet",
"serviceAnomalyStats": Object {
"actualValue": 868025.86875,
"anomalyScore": 0,
"healthStatus": "healthy",
"jobId": "apm-production-6117-high_mean_transaction_duration",
"serviceName": "opbeans-dotnet",
"transactionType": "request",
},
},
},
Object {
"data": Object {
"agent.name": "go",
"id": "opbeans-go",
"service.environment": "testing",
"service.name": "opbeans-go",
"serviceAnomalyStats": Object {
"actualValue": 102786.319148936,
"anomalyScore": 0,
"healthStatus": "healthy",
"jobId": "apm-testing-41e5-high_mean_transaction_duration",
"serviceName": "opbeans-go",
"transactionType": "request",
},
},
},
Object {
"data": Object {
"agent.name": "java",
"id": "opbeans-java",
"service.environment": "production",
"service.name": "opbeans-java",
"serviceAnomalyStats": Object {
"actualValue": 175568.855769231,
"anomalyScore": 0,
"healthStatus": "healthy",
"jobId": "apm-production-6117-high_mean_transaction_duration",
"serviceName": "opbeans-java",
"transactionType": "request",
},
},
},
]
`);
Array [
Object {
"data": Object {
"agent.name": "dotnet",
"id": "opbeans-dotnet",
"service.environment": "production",
"service.name": "opbeans-dotnet",
"serviceAnomalyStats": Object {
"actualValue": 868025.86875,
"anomalyScore": 0,
"healthStatus": "healthy",
"jobId": "apm-production-6117-high_mean_transaction_duration",
"serviceName": "opbeans-dotnet",
"transactionType": "request",
},
},
},
Object {
"data": Object {
"agent.name": "go",
"id": "opbeans-go",
"service.environment": "testing",
"service.name": "opbeans-go",
"serviceAnomalyStats": Object {
"actualValue": 102786.319148936,
"anomalyScore": 0,
"healthStatus": "healthy",
"jobId": "apm-testing-41e5-high_mean_transaction_duration",
"serviceName": "opbeans-go",
"transactionType": "request",
},
},
},
Object {
"data": Object {
"agent.name": "java",
"id": "opbeans-java",
"service.environment": "production",
"service.name": "opbeans-java",
"serviceAnomalyStats": Object {
"actualValue": 175568.855769231,
"anomalyScore": 0,
"healthStatus": "healthy",
"jobId": "apm-production-6117-high_mean_transaction_duration",
"serviceName": "opbeans-java",
"transactionType": "request",
},
},
},
]
`);
});
});
describe('with a user that does not have access to ML', () => {
before(async () => {
response = await supertestAsApmReadUserWithoutMlAccess.get(
`/internal/apm/service-map?start=${start}&end=${end}&environment=ENVIRONMENT_ALL`
);
response = await apmApiClient.noMlAccessUser({
endpoint: `GET /internal/apm/service-map`,
params: {
query: {
environment: 'ENVIRONMENT_ALL',
start: metadata.start,
end: metadata.end,
},
},
});
});
it('returns service map elements without anomaly stats', () => {
expect(response.status).to.be(200);
const dataWithAnomalies = response.body.elements.filter(
(el: { data: { serviceAnomalyStats?: {} } }) => !isEmpty(el.data.serviceAnomalyStats)
(el) => !isEmpty((el.data as ServiceConnectionNode).serviceAnomalyStats)
);
expect(dataWithAnomalies).to.be.empty();
});
});
@ -261,20 +297,25 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext)
describe('with a single service', () => {
describe('when ENVIRONMENT_ALL is selected', () => {
it('returns service map elements', async () => {
response = await supertest.get(
url.format({
pathname: '/internal/apm/service-map',
before(async () => {
response = await apmApiClient.readUser({
endpoint: `GET /internal/apm/service-map`,
params: {
query: {
environment: 'ENVIRONMENT_ALL',
start: metadata.start,
end: metadata.end,
serviceName: 'opbeans-java',
},
})
);
},
});
});
it('retuns status code 200', () => {
expect(response.status).to.be(200);
});
it('returns some elements', () => {
expect(response.body.elements.length).to.be.greaterThan(1);
});
});
@ -282,51 +323,79 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext)
});
describe('/internal/apm/service-map/service/{serviceName}', () => {
it('returns an object with data', async () => {
const q = querystring.stringify({
start: metadata.start,
end: metadata.end,
environment: 'ENVIRONMENT_ALL',
});
const response = await supertest.get(`/internal/apm/service-map/service/opbeans-node?${q}`);
expect(response.status).to.be(200);
expectSnapshot(response.body).toMatchInline(`
Object {
"avgCpuUsage": 0.240216666666667,
"avgErrorRate": 0,
"avgMemoryUsage": 0.202572668763642,
"transactionStats": Object {
"avgRequestsPerMinute": 5.2,
"avgTransactionDuration": 53906.6603773585,
let response: ServiceNodeResponse;
before(async () => {
response = await apmApiClient.readUser({
endpoint: `GET /internal/apm/service-map/service/{serviceName}`,
params: {
path: { serviceName: 'opbeans-node' },
query: {
start: metadata.start,
end: metadata.end,
environment: 'ENVIRONMENT_ALL',
},
}
`);
},
});
});
it('retuns status code 200', () => {
expect(response.status).to.be(200);
});
it('returns some error rate', () => {
expect(response.body.failedTransactionsRate?.value).to.eql(0);
expect(response.body.failedTransactionsRate?.timeseries?.length).to.be.greaterThan(0);
});
it('returns some latency', () => {
expect(response.body.transactionStats?.latency?.value).to.be.greaterThan(0);
expect(response.body.transactionStats?.latency?.timeseries?.length).to.be.greaterThan(0);
});
it('returns some throughput', () => {
expect(response.body.transactionStats?.throughput?.value).to.be.greaterThan(0);
expect(response.body.transactionStats?.throughput?.timeseries?.length).to.be.greaterThan(0);
});
it('returns some cpu usage', () => {
expect(response.body.cpuUsage?.value).to.be.greaterThan(0);
expect(response.body.cpuUsage?.timeseries?.length).to.be.greaterThan(0);
});
});
describe('/internal/apm/service-map/backend', () => {
it('returns an object with data', async () => {
const q = querystring.stringify({
backendName: 'postgresql',
start: metadata.start,
end: metadata.end,
environment: 'ENVIRONMENT_ALL',
});
const response = await supertest.get(`/internal/apm/service-map/backend?${q}`);
expect(response.status).to.be(200);
expectSnapshot(response.body).toMatchInline(`
Object {
"avgErrorRate": 0,
"transactionStats": Object {
"avgRequestsPerMinute": 82.9666666666667,
"avgTransactionDuration": 18307.583366814,
let response: BackendResponse;
before(async () => {
response = await apmApiClient.readUser({
endpoint: `GET /internal/apm/service-map/backend`,
params: {
query: {
backendName: 'postgresql',
start: metadata.start,
end: metadata.end,
environment: 'ENVIRONMENT_ALL',
},
}
`);
},
});
});
it('retuns status code 200', () => {
expect(response.status).to.be(200);
});
it('returns some error rate', () => {
expect(response.body.failedTransactionsRate?.value).to.eql(0);
expect(response.body.failedTransactionsRate?.timeseries?.length).to.be.greaterThan(0);
});
it('returns some latency', () => {
expect(response.body.transactionStats?.latency?.value).to.be.greaterThan(0);
expect(response.body.transactionStats?.latency?.timeseries?.length).to.be.greaterThan(0);
});
it('returns some throughput', () => {
expect(response.body.transactionStats?.throughput?.value).to.be.greaterThan(0);
expect(response.body.transactionStats?.throughput?.timeseries?.length).to.be.greaterThan(0);
});
});
});