[APM] Correlations UI POC (#82256)

This commit is contained in:
Søren Louv-Jansen 2020-11-20 08:50:34 +01:00 committed by GitHub
parent 54ee94d8e8
commit 68b5625e5a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1251 additions and 517 deletions

View file

@ -0,0 +1,152 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
ScaleType,
Chart,
LineSeries,
Axis,
CurveType,
Position,
timeFormatter,
Settings,
} from '@elastic/charts';
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { EuiTitle, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useUrlParams } from '../../../hooks/useUrlParams';
import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher';
import {
APIReturnType,
callApmApi,
} from '../../../services/rest/createCallApmApi';
import { px } from '../../../style/variables';
import { SignificantTermsTable } from './SignificantTermsTable';
import { ChartContainer } from '../../shared/charts/chart_container';
type CorrelationsApiResponse = NonNullable<
APIReturnType<'GET /api/apm/correlations/failed_transactions'>
>;
type SignificantTerm = NonNullable<
CorrelationsApiResponse['significantTerms']
>[0];
export function ErrorCorrelations() {
const [
selectedSignificantTerm,
setSelectedSignificantTerm,
] = useState<SignificantTerm | null>(null);
const { serviceName } = useParams<{ serviceName?: string }>();
const { urlParams, uiFilters } = useUrlParams();
const { transactionName, transactionType, start, end } = urlParams;
const { data, status } = useFetcher(() => {
if (start && end) {
return callApmApi({
endpoint: 'GET /api/apm/correlations/failed_transactions',
params: {
query: {
serviceName,
transactionName,
transactionType,
start,
end,
uiFilters: JSON.stringify(uiFilters),
fieldNames:
'transaction.name,user.username,user.id,host.ip,user_agent.name,kubernetes.pod.uuid,kubernetes.pod.name,url.domain,container.id,service.node.name',
},
},
});
}
}, [serviceName, start, end, transactionName, transactionType, uiFilters]);
return (
<>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiTitle size="s">
<h4>Error rate over time</h4>
</EuiTitle>
<ErrorTimeseriesChart
data={data}
status={status}
selectedSignificantTerm={selectedSignificantTerm}
/>
</EuiFlexItem>
<EuiFlexItem>
<SignificantTermsTable
significantTerms={data?.significantTerms}
status={status}
setSelectedSignificantTerm={setSelectedSignificantTerm}
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}
function ErrorTimeseriesChart({
data,
selectedSignificantTerm,
status,
}: {
data?: CorrelationsApiResponse;
selectedSignificantTerm: SignificantTerm | null;
status: FETCH_STATUS;
}) {
const dateFormatter = timeFormatter('HH:mm:ss');
return (
<ChartContainer height={200} hasData={!!data} status={status}>
<Chart size={{ height: px(200), width: px(600) }}>
<Settings showLegend legendPosition={Position.Bottom} />
<Axis
id="bottom"
position={Position.Bottom}
showOverlappingTicks
tickFormat={dateFormatter}
/>
<Axis
id="left"
position={Position.Left}
domain={{ min: 0, max: 1 }}
tickFormat={(d) => `${roundFloat(d * 100)}%`}
/>
<LineSeries
id="Overall error rate"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor={'x'}
yAccessors={['y']}
data={data?.overall?.timeseries ?? []}
curve={CurveType.CURVE_MONOTONE_X}
/>
{selectedSignificantTerm !== null ? (
<LineSeries
id="Error rate for selected term"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor={'x'}
yAccessors={['y']}
color="red"
data={selectedSignificantTerm.timeseries}
curve={CurveType.CURVE_MONOTONE_X}
/>
) : null}
</Chart>
</ChartContainer>
);
}
function roundFloat(n: number, digits = 2) {
const factor = Math.pow(10, digits);
return Math.round(n * factor) / factor;
}

View file

@ -0,0 +1,273 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
ScaleType,
Chart,
LineSeries,
Axis,
CurveType,
BarSeries,
Position,
timeFormatter,
Settings,
} from '@elastic/charts';
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { EuiTitle, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { getDurationFormatter } from '../../../../common/utils/formatters';
import { useUrlParams } from '../../../hooks/useUrlParams';
import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher';
import {
APIReturnType,
callApmApi,
} from '../../../services/rest/createCallApmApi';
import { SignificantTermsTable } from './SignificantTermsTable';
import { ChartContainer } from '../../shared/charts/chart_container';
type CorrelationsApiResponse = NonNullable<
APIReturnType<'GET /api/apm/correlations/slow_transactions'>
>;
type SignificantTerm = NonNullable<
CorrelationsApiResponse['significantTerms']
>[0];
export function LatencyCorrelations() {
const [
selectedSignificantTerm,
setSelectedSignificantTerm,
] = useState<SignificantTerm | null>(null);
const { serviceName } = useParams<{ serviceName?: string }>();
const { urlParams, uiFilters } = useUrlParams();
const { transactionName, transactionType, start, end } = urlParams;
const { data, status } = useFetcher(() => {
if (start && end) {
return callApmApi({
endpoint: 'GET /api/apm/correlations/slow_transactions',
params: {
query: {
serviceName,
transactionName,
transactionType,
start,
end,
uiFilters: JSON.stringify(uiFilters),
durationPercentile: '50',
fieldNames:
'user.username,user.id,host.ip,user_agent.name,kubernetes.pod.uuid,kubernetes.pod.name,url.domain,container.id,service.node.name',
},
},
});
}
}, [serviceName, start, end, transactionName, transactionType, uiFilters]);
return (
<>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiFlexGroup direction="row">
<EuiFlexItem>
<EuiTitle size="s">
<h4>Average latency over time</h4>
</EuiTitle>
<LatencyTimeseriesChart
data={data}
status={status}
selectedSignificantTerm={selectedSignificantTerm}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="s">
<h4>Latency distribution</h4>
</EuiTitle>
<LatencyDistributionChart
data={data}
status={status}
selectedSignificantTerm={selectedSignificantTerm}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<SignificantTermsTable
significantTerms={data?.significantTerms}
status={status}
setSelectedSignificantTerm={setSelectedSignificantTerm}
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}
function getTimeseriesYMax(data?: CorrelationsApiResponse) {
if (!data?.overall) {
return 0;
}
const yValues = [
...data.overall.timeseries.map((p) => p.y ?? 0),
...data.significantTerms.flatMap((term) =>
term.timeseries.map((p) => p.y ?? 0)
),
];
return Math.max(...yValues);
}
function getDistributionYMax(data?: CorrelationsApiResponse) {
if (!data?.overall) {
return 0;
}
const yValues = [
...data.overall.distribution.map((p) => p.y ?? 0),
...data.significantTerms.flatMap((term) =>
term.distribution.map((p) => p.y ?? 0)
),
];
return Math.max(...yValues);
}
function LatencyTimeseriesChart({
data,
selectedSignificantTerm,
status,
}: {
data?: CorrelationsApiResponse;
selectedSignificantTerm: SignificantTerm | null;
status: FETCH_STATUS;
}) {
const dateFormatter = timeFormatter('HH:mm:ss');
const yMax = getTimeseriesYMax(data);
const durationFormatter = getDurationFormatter(yMax);
return (
<ChartContainer height={200} hasData={!!data} status={status}>
<Chart>
<Settings showLegend legendPosition={Position.Bottom} />
<Axis
id="bottom"
position={Position.Bottom}
showOverlappingTicks
tickFormat={dateFormatter}
/>
<Axis
id="left"
position={Position.Left}
domain={{ min: 0, max: yMax }}
tickFormat={(d) => durationFormatter(d).formatted}
/>
<LineSeries
id="Overall latency"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor={'x'}
yAccessors={['y']}
data={data?.overall?.timeseries || []}
curve={CurveType.CURVE_MONOTONE_X}
/>
{selectedSignificantTerm !== null ? (
<LineSeries
id="Latency for selected term"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor={'x'}
yAccessors={['y']}
color="red"
data={selectedSignificantTerm.timeseries}
curve={CurveType.CURVE_MONOTONE_X}
/>
) : null}
</Chart>
</ChartContainer>
);
}
function LatencyDistributionChart({
data,
selectedSignificantTerm,
status,
}: {
data?: CorrelationsApiResponse;
selectedSignificantTerm: SignificantTerm | null;
status: FETCH_STATUS;
}) {
const xMax = Math.max(
...(data?.overall?.distribution.map((p) => p.x ?? 0) ?? [])
);
const durationFormatter = getDurationFormatter(xMax);
const yMax = getDistributionYMax(data);
return (
<ChartContainer height={200} hasData={!!data} status={status}>
<Chart>
<Settings
showLegend
legendPosition={Position.Bottom}
tooltip={{
headerFormatter: (obj) => {
const start = durationFormatter(obj.value);
const end = durationFormatter(
obj.value + data?.distributionInterval
);
return `${start.value} - ${end.formatted}`;
},
}}
/>
<Axis
id="x-axis"
position={Position.Bottom}
showOverlappingTicks
tickFormat={(d) => durationFormatter(d).formatted}
/>
<Axis
id="y-axis"
position={Position.Left}
tickFormat={(d) => `${d}%`}
domain={{ min: 0, max: yMax }}
/>
<BarSeries
id="Overall latency distribution"
xScaleType={ScaleType.Linear}
yScaleType={ScaleType.Linear}
xAccessor={'x'}
yAccessors={['y']}
data={data?.overall?.distribution || []}
minBarHeight={5}
tickFormat={(d) => `${roundFloat(d)}%`}
/>
{selectedSignificantTerm !== null ? (
<BarSeries
id="Latency distribution for selected term"
xScaleType={ScaleType.Linear}
yScaleType={ScaleType.Linear}
xAccessor={'x'}
yAccessors={['y']}
color="red"
data={selectedSignificantTerm.distribution}
minBarHeight={5}
tickFormat={(d) => `${roundFloat(d)}%`}
/>
) : null}
</Chart>
</ChartContainer>
);
}
function roundFloat(n: number, digits = 2) {
const factor = Math.pow(10, digits);
return Math.round(n * factor) / factor;
}

View file

@ -0,0 +1,119 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiBadge, EuiIcon, EuiToolTip, EuiLink } from '@elastic/eui';
import { useHistory } from 'react-router-dom';
import { EuiBasicTable } from '@elastic/eui';
import { asPercent, asInteger } from '../../../../common/utils/formatters';
import { APIReturnType } from '../../../services/rest/createCallApmApi';
import { FETCH_STATUS } from '../../../hooks/useFetcher';
import { createHref } from '../../shared/Links/url_helpers';
type CorrelationsApiResponse =
| APIReturnType<'GET /api/apm/correlations/failed_transactions'>
| APIReturnType<'GET /api/apm/correlations/slow_transactions'>;
type SignificantTerm = NonNullable<
NonNullable<CorrelationsApiResponse>['significantTerms']
>[0];
interface Props<T> {
significantTerms?: T[];
status: FETCH_STATUS;
setSelectedSignificantTerm: (term: T | null) => void;
}
export function SignificantTermsTable<T extends SignificantTerm>({
significantTerms,
status,
setSelectedSignificantTerm,
}: Props<T>) {
const history = useHistory();
const columns = [
{
field: 'matches',
name: 'Matches',
render: (_: any, term: T) => {
return (
<EuiToolTip
position="top"
content={`(${asInteger(term.fgCount)} of ${asInteger(
term.bgCount
)} requests)`}
>
<>
<EuiBadge
color={
term.fgCount / term.bgCount > 0.03 ? 'primary' : 'secondary'
}
>
{asPercent(term.fgCount, term.bgCount)}
</EuiBadge>
({Math.round(term.score)})
</>
</EuiToolTip>
);
},
},
{
field: 'fieldName',
name: 'Field name',
},
{
field: 'filedValue',
name: 'Field value',
render: (_: any, term: T) => String(term.fieldValue).slice(0, 50),
},
{
field: 'filedValue',
name: '',
render: (_: any, term: T) => {
return (
<>
<EuiLink
href={createHref(history, {
query: {
kuery: `${term.fieldName}:"${encodeURIComponent(
term.fieldValue
)}"`,
},
})}
>
<EuiIcon type="magnifyWithPlus" />
</EuiLink>
<EuiLink
href={createHref(history, {
query: {
kuery: `not ${term.fieldName}:"${encodeURIComponent(
term.fieldValue
)}"`,
},
})}
>
<EuiIcon type="magnifyWithMinus" />
</EuiLink>
</>
);
},
},
];
return (
<EuiBasicTable
items={significantTerms ?? []}
noItemsMessage={status === FETCH_STATUS.LOADING ? 'Loading' : 'No data'}
loading={status === FETCH_STATUS.LOADING}
columns={columns}
rowProps={(term) => {
return {
onMouseEnter: () => setSelectedSignificantTerm(term),
onMouseLeave: () => setSelectedSignificantTerm(null),
};
}}
/>
);
}

View file

@ -4,82 +4,75 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import url from 'url';
import { useParams } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { EuiTitle, EuiListGroup } from '@elastic/eui';
import { useUrlParams } from '../../../hooks/useUrlParams';
import React, { useState } from 'react';
import {
EuiButtonEmpty,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiTitle,
EuiPortal,
EuiCode,
EuiLink,
EuiCallOut,
EuiButton,
} from '@elastic/eui';
import { useHistory } from 'react-router-dom';
import { enableCorrelations } from '../../../../common/ui_settings_keys';
import { useApmPluginContext } from '../../../hooks/useApmPluginContext';
const SESSION_STORAGE_KEY = 'apm.debug.show_correlations';
import { LatencyCorrelations } from './LatencyCorrelations';
import { ErrorCorrelations } from './ErrorCorrelations';
import { useUrlParams } from '../../../hooks/useUrlParams';
import { createHref } from '../../shared/Links/url_helpers';
export function Correlations() {
const location = useLocation();
const { serviceName } = useParams<{ serviceName?: string }>();
const { urlParams, uiFilters } = useUrlParams();
const { core } = useApmPluginContext();
const { transactionName, transactionType, start, end } = urlParams;
if (
!location.search.includes('&_show_correlations') &&
sessionStorage.getItem(SESSION_STORAGE_KEY) !== 'true'
) {
const { uiSettings } = useApmPluginContext().core;
const { urlParams } = useUrlParams();
const history = useHistory();
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
if (!uiSettings.get(enableCorrelations)) {
return null;
}
sessionStorage.setItem(SESSION_STORAGE_KEY, 'true');
const query = {
serviceName,
transactionName,
transactionType,
start,
end,
uiFilters: JSON.stringify(uiFilters),
fieldNames:
'user.username,user.id,host.ip,user_agent.name,kubernetes.pod.uuid,url.domain,container.id,service.node.name',
};
const listItems = [
{
label: 'Show correlations between two ranges',
href: url.format({
query: {
...query,
gap: 24,
},
pathname: core.http.basePath.prepend(`/api/apm/correlations/ranges`),
}),
isDisabled: false,
iconType: 'tokenRange',
size: 's' as const,
},
{
label: 'Show correlations for slow transactions',
href: url.format({
query: {
...query,
durationPercentile: 95,
},
pathname: core.http.basePath.prepend(
`/api/apm/correlations/slow_durations`
),
}),
isDisabled: false,
iconType: 'clock',
size: 's' as const,
},
];
return (
<>
<EuiTitle>
<h2>Correlations</h2>
</EuiTitle>
<EuiButton
onClick={() => {
setIsFlyoutVisible(true);
}}
>
View correlations
</EuiButton>
<EuiListGroup listItems={listItems} />
{isFlyoutVisible && (
<EuiPortal>
<EuiFlyout
size="l"
ownFocus
onClose={() => setIsFlyoutVisible(false)}
>
<EuiFlyoutHeader hasBorder aria-labelledby="correlations-flyout">
<EuiTitle>
<h2 id="correlations-flyout">Correlations</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{urlParams.kuery ? (
<EuiCallOut size="m">
<span>Filtering by</span>
<EuiCode>{urlParams.kuery}</EuiCode>
<EuiLink href={createHref(history, { query: { kuery: '' } })}>
<EuiButtonEmpty iconType="cross">Clear</EuiButtonEmpty>
</EuiLink>
</EuiCallOut>
) : null}
<LatencyCorrelations />
<ErrorCorrelations />
</EuiFlyoutBody>
</EuiFlyout>
</EuiPortal>
)}
</>
);
}

View file

@ -119,9 +119,9 @@ export function TransactionDetails({
</ApmHeader>
<SearchBar />
<EuiPage>
<Correlations />
<EuiFlexGroup>
<EuiFlexItem grow={1}>
<Correlations />
<LocalUIFilters {...localUIFiltersConfig} />
</EuiFlexItem>
<EuiFlexItem grow={7}>

View file

@ -123,10 +123,11 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) {
return (
<>
<SearchBar />
<Correlations />
<EuiPage>
<EuiFlexGroup>
<EuiFlexItem grow={1}>
<Correlations />
<LocalUIFilters {...localFiltersConfig}>
<TransactionTypeFilter
transactionTypes={serviceTransactionTypes}

View file

@ -129,9 +129,9 @@ export function ServiceInventory() {
<>
<SearchBar />
<EuiPage>
<Correlations />
<EuiFlexGroup>
<EuiFlexItem grow={1}>
<Correlations />
<LocalUIFilters {...localFiltersConfig} />
</EuiFlexItem>
<EuiFlexItem grow={7}>

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { History } from 'history';
import { parse, stringify } from 'query-string';
import { url } from '../../../../../../../src/plugins/kibana_utils/public';
import { LocalUIFilterName } from '../../../../common/ui_filter';
@ -20,6 +21,48 @@ export function fromQuery(query: Record<string, any>) {
return stringify(encodedQuery, { sort: false, encode: false });
}
type LocationWithQuery = Partial<
History['location'] & {
query: Record<string, string>;
}
>;
function getNextLocation(
history: History,
locationWithQuery: LocationWithQuery
) {
const { query, ...rest } = locationWithQuery;
return {
...history.location,
...rest,
search: fromQuery({
...toQuery(history.location.search),
...query,
}),
};
}
export function replace(
history: History,
locationWithQuery: LocationWithQuery
) {
const location = getNextLocation(history, locationWithQuery);
return history.replace(location);
}
export function push(history: History, locationWithQuery: LocationWithQuery) {
const location = getNextLocation(history, locationWithQuery);
return history.push(location);
}
export function createHref(
history: History,
locationWithQuery: LocationWithQuery
) {
const location = getNextLocation(history, locationWithQuery);
return history.createHref(location);
}
export type APMQueryParams = {
transactionId?: string;
transactionName?: string;

View file

@ -115,6 +115,12 @@ For debugging access Elasticsearch on http://localhost:9220` (elastic/changeme)
_Note: Run the following commands from `kibana/`._
### Typescript
```
yarn tsc --noEmit --project x-pack/tsconfig.json --skipLibCheck
```
### Prettier
```

View file

@ -10,7 +10,6 @@ import { snakeCase } from 'lodash';
import Boom from '@hapi/boom';
import { ProcessorEvent } from '../../../common/processor_event';
import { ML_ERRORS } from '../../../common/anomaly_detection';
import { PromiseReturnType } from '../../../../observability/typings/common';
import { Setup } from '../helpers/setup_request';
import {
TRANSACTION_DURATION,
@ -19,9 +18,6 @@ import {
import { APM_ML_JOB_GROUP, ML_MODULE_ID_APM_TRANSACTION } from './constants';
import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es';
export type CreateAnomalyDetectionJobsAPIResponse = PromiseReturnType<
typeof createAnomalyDetectionJobs
>;
export async function createAnomalyDetectionJobs(
setup: Setup,
environments: string[],

View file

@ -0,0 +1,180 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { isEmpty } from 'lodash';
import { EventOutcome } from '../../../../common/event_outcome';
import {
formatTopSignificantTerms,
TopSigTerm,
} from '../get_correlations_for_slow_transactions/format_top_significant_terms';
import { AggregationOptionsByType } from '../../../../../../typings/elasticsearch/aggregations';
import { ESFilter } from '../../../../../../typings/elasticsearch';
import { rangeFilter } from '../../../../common/utils/range_filter';
import {
EVENT_OUTCOME,
SERVICE_NAME,
TRANSACTION_NAME,
TRANSACTION_TYPE,
} from '../../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../../common/processor_event';
import { Setup, SetupTimeRange } from '../../helpers/setup_request';
import { getBucketSize } from '../../helpers/get_bucket_size';
import {
getOutcomeAggregation,
getTransactionErrorRateTimeSeries,
} from '../../helpers/transaction_error_rate';
export async function getCorrelationsForFailedTransactions({
serviceName,
transactionType,
transactionName,
fieldNames,
setup,
}: {
serviceName: string | undefined;
transactionType: string | undefined;
transactionName: string | undefined;
fieldNames: string[];
setup: Setup & SetupTimeRange;
}) {
const { start, end, esFilter, apmEventClient } = setup;
const backgroundFilters: ESFilter[] = [
...esFilter,
{ range: rangeFilter(start, end) },
];
if (serviceName) {
backgroundFilters.push({ term: { [SERVICE_NAME]: serviceName } });
}
if (transactionType) {
backgroundFilters.push({ term: { [TRANSACTION_TYPE]: transactionType } });
}
if (transactionName) {
backgroundFilters.push({ term: { [TRANSACTION_NAME]: transactionName } });
}
const params = {
apm: { events: [ProcessorEvent.transaction] },
body: {
size: 0,
query: {
bool: {
// foreground filters
filter: [
...backgroundFilters,
{ term: { [EVENT_OUTCOME]: EventOutcome.failure } },
],
},
},
aggs: fieldNames.reduce((acc, fieldName) => {
return {
...acc,
[fieldName]: {
significant_terms: {
size: 10,
field: fieldName,
background_filter: { bool: { filter: backgroundFilters } },
},
},
};
}, {} as Record<string, { significant_terms: AggregationOptionsByType['significant_terms'] }>),
},
};
const response = await apmEventClient.search(params);
const topSigTerms = formatTopSignificantTerms(response.aggregations);
return getChartsForTopSigTerms({ setup, backgroundFilters, topSigTerms });
}
export async function getChartsForTopSigTerms({
setup,
backgroundFilters,
topSigTerms,
}: {
setup: Setup & SetupTimeRange;
backgroundFilters: ESFilter[];
topSigTerms: TopSigTerm[];
}) {
const { start, end, apmEventClient } = setup;
const { intervalString } = getBucketSize({ start, end, numBuckets: 30 });
if (isEmpty(topSigTerms)) {
return {};
}
const timeseriesAgg = {
date_histogram: {
field: '@timestamp',
fixed_interval: intervalString,
min_doc_count: 0,
extended_bounds: { min: start, max: end },
},
aggs: {
// TODO: add support for metrics
outcomes: getOutcomeAggregation({ searchAggregatedTransactions: false }),
},
};
const perTermAggs = topSigTerms.reduce(
(acc, term, index) => {
acc[`term_${index}`] = {
filter: { term: { [term.fieldName]: term.fieldValue } },
aggs: { timeseries: timeseriesAgg },
};
return acc;
},
{} as Record<
string,
{
filter: AggregationOptionsByType['filter'];
aggs: { timeseries: typeof timeseriesAgg };
}
>
);
const params = {
// TODO: add support for metrics
apm: { events: [ProcessorEvent.transaction] },
body: {
size: 0,
query: { bool: { filter: backgroundFilters } },
aggs: {
// overall aggs
timeseries: timeseriesAgg,
// per term aggs
...perTermAggs,
},
},
};
const response = await apmEventClient.search(params);
type Agg = NonNullable<typeof response.aggregations>;
if (!response.aggregations) {
return {};
}
return {
overall: {
timeseries: getTransactionErrorRateTimeSeries(
response.aggregations.timeseries.buckets
),
},
significantTerms: topSigTerms.map((topSig, index) => {
// @ts-expect-error
const agg = response.aggregations[`term_${index}`] as Agg;
return {
...topSig,
timeseries: getTransactionErrorRateTimeSeries(agg.timeseries.buckets),
};
}),
};
}

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { orderBy } from 'lodash';
import {
AggregationOptionsByType,
AggregationResultOf,
} from '../../../../../../typings/elasticsearch/aggregations';
export interface TopSigTerm {
bgCount: number;
fgCount: number;
fieldName: string;
fieldValue: string | number;
score: number;
}
type SigTermAggs = AggregationResultOf<
{ significant_terms: AggregationOptionsByType['significant_terms'] },
{}
>;
export function formatTopSignificantTerms(
aggregations?: Record<string, SigTermAggs>
) {
const significantTerms = Object.entries(aggregations ?? []).flatMap(
([fieldName, agg]) => {
return agg.buckets.map((bucket) => ({
fieldName,
fieldValue: bucket.key,
bgCount: bucket.bg_count,
fgCount: bucket.doc_count,
score: bucket.score,
}));
}
);
// get top 10 terms ordered by score
const topSigTerms = orderBy(significantTerms, 'score', 'desc').slice(0, 10);
return topSigTerms;
}

View file

@ -0,0 +1,165 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { isEmpty } from 'lodash';
import { AggregationOptionsByType } from '../../../../../../typings/elasticsearch/aggregations';
import { ESFilter } from '../../../../../../typings/elasticsearch';
import { TRANSACTION_DURATION } from '../../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../../common/processor_event';
import { Setup, SetupTimeRange } from '../../helpers/setup_request';
import { getBucketSize } from '../../helpers/get_bucket_size';
import { TopSigTerm } from './format_top_significant_terms';
import { getMaxLatency } from './get_max_latency';
export async function getChartsForTopSigTerms({
setup,
backgroundFilters,
topSigTerms,
}: {
setup: Setup & SetupTimeRange;
backgroundFilters: ESFilter[];
topSigTerms: TopSigTerm[];
}) {
const { start, end, apmEventClient } = setup;
const { intervalString } = getBucketSize({ start, end, numBuckets: 30 });
if (isEmpty(topSigTerms)) {
return {};
}
const maxLatency = await getMaxLatency({
setup,
backgroundFilters,
topSigTerms,
});
if (!maxLatency) {
return {};
}
const intervalBuckets = 20;
const distributionInterval = roundtoTenth(maxLatency / intervalBuckets);
const distributionAgg = {
// filter out outliers not included in the significant term docs
filter: { range: { [TRANSACTION_DURATION]: { lte: maxLatency } } },
aggs: {
dist_filtered_by_latency: {
histogram: {
// TODO: add support for metrics
field: TRANSACTION_DURATION,
interval: distributionInterval,
min_doc_count: 0,
extended_bounds: {
min: 0,
max: maxLatency,
},
},
},
},
};
const timeseriesAgg = {
date_histogram: {
field: '@timestamp',
fixed_interval: intervalString,
min_doc_count: 0,
extended_bounds: { min: start, max: end },
},
aggs: {
average: {
avg: {
// TODO: add support for metrics
field: TRANSACTION_DURATION,
},
},
},
};
const perTermAggs = topSigTerms.reduce(
(acc, term, index) => {
acc[`term_${index}`] = {
filter: { term: { [term.fieldName]: term.fieldValue } },
aggs: {
distribution: distributionAgg,
timeseries: timeseriesAgg,
},
};
return acc;
},
{} as Record<
string,
{
filter: AggregationOptionsByType['filter'];
aggs: {
distribution: typeof distributionAgg;
timeseries: typeof timeseriesAgg;
};
}
>
);
const params = {
// TODO: add support for metrics
apm: { events: [ProcessorEvent.transaction] },
body: {
size: 0,
query: { bool: { filter: backgroundFilters } },
aggs: {
// overall aggs
distribution: distributionAgg,
timeseries: timeseriesAgg,
// per term aggs
...perTermAggs,
},
},
};
const response = await apmEventClient.search(params);
type Agg = NonNullable<typeof response.aggregations>;
if (!response.aggregations) {
return;
}
function formatTimeseries(timeseries: Agg['timeseries']) {
return timeseries.buckets.map((bucket) => ({
x: bucket.key,
y: bucket.average.value,
}));
}
function formatDistribution(distribution: Agg['distribution']) {
const total = distribution.doc_count;
return distribution.dist_filtered_by_latency.buckets.map((bucket) => ({
x: bucket.key,
y: (bucket.doc_count / total) * 100,
}));
}
return {
distributionInterval,
overall: {
timeseries: formatTimeseries(response.aggregations.timeseries),
distribution: formatDistribution(response.aggregations.distribution),
},
significantTerms: topSigTerms.map((topSig, index) => {
// @ts-expect-error
const agg = response.aggregations[`term_${index}`] as Agg;
return {
...topSig,
timeseries: formatTimeseries(agg.timeseries),
distribution: formatDistribution(agg.distribution),
};
}),
};
}
function roundtoTenth(v: number) {
return Math.pow(10, Math.round(Math.log10(v)));
}

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ESFilter } from '../../../../../../typings/elasticsearch';
import { TRANSACTION_DURATION } from '../../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../../common/processor_event';
import { Setup, SetupTimeRange } from '../../helpers/setup_request';
import { TopSigTerm } from './format_top_significant_terms';
export async function getMaxLatency({
setup,
backgroundFilters,
topSigTerms,
}: {
setup: Setup & SetupTimeRange;
backgroundFilters: ESFilter[];
topSigTerms: TopSigTerm[];
}) {
const { apmEventClient } = setup;
const params = {
// TODO: add support for metrics
apm: { events: [ProcessorEvent.transaction] },
body: {
size: 0,
query: {
bool: {
filter: backgroundFilters,
// only include docs containing the significant terms
should: topSigTerms.map((term) => ({
term: { [term.fieldName]: term.fieldValue },
})),
minimum_should_match: 1,
},
},
aggs: {
// TODO: add support for metrics
// max_latency: { max: { field: TRANSACTION_DURATION } },
max_latency: {
percentiles: { field: TRANSACTION_DURATION, percents: [99] },
},
},
},
};
const response = await apmEventClient.search(params);
// return response.aggregations?.max_latency.value;
return Object.values(response.aggregations?.max_latency.values ?? {})[0];
}

View file

@ -4,7 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { AggregationOptionsByType } from '../../../../../../typings/elasticsearch/aggregations';
import { ESFilter } from '../../../../../../typings/elasticsearch';
import { rangeFilter } from '../../../../common/utils/range_filter';
import {
SERVICE_NAME,
TRANSACTION_DURATION,
@ -12,15 +14,10 @@ import {
TRANSACTION_TYPE,
} from '../../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../../common/processor_event';
import { asDuration } from '../../../../common/utils/formatters';
import { rangeFilter } from '../../../../common/utils/range_filter';
import { Setup, SetupTimeRange } from '../../helpers/setup_request';
import { getDurationForPercentile } from './get_duration_for_percentile';
import {
formatAggregationResponse,
getSignificantTermsAgg,
} from './get_significant_terms_agg';
import { SignificantTermsScoring } from './scoring_rt';
import { formatTopSignificantTerms } from './format_top_significant_terms';
import { getChartsForTopSigTerms } from './get_charts_for_top_sig_terms';
export async function getCorrelationsForSlowTransactions({
serviceName,
@ -28,13 +25,11 @@ export async function getCorrelationsForSlowTransactions({
transactionName,
durationPercentile,
fieldNames,
scoring,
setup,
}: {
serviceName: string | undefined;
transactionType: string | undefined;
transactionName: string | undefined;
scoring: SignificantTermsScoring;
durationPercentile: number;
fieldNames: string[];
setup: Setup & SetupTimeRange;
@ -79,16 +74,22 @@ export async function getCorrelationsForSlowTransactions({
],
},
},
aggs: getSignificantTermsAgg({ fieldNames, backgroundFilters, scoring }),
aggs: fieldNames.reduce((acc, fieldName) => {
return {
...acc,
[fieldName]: {
significant_terms: {
size: 10,
field: fieldName,
background_filter: { bool: { filter: backgroundFilters } },
},
},
};
}, {} as Record<string, { significant_terms: AggregationOptionsByType['significant_terms'] }>),
},
};
const response = await apmEventClient.search(params);
return {
message: `Showing significant fields for transactions slower than ${durationPercentile}th percentile (${asDuration(
durationForPercentile
)})`,
response: formatAggregationResponse(response.aggregations),
};
const topSigTerms = formatTopSignificantTerms(response.aggregations);
return getChartsForTopSigTerms({ setup, backgroundFilters, topSigTerms });
}

View file

@ -8,11 +8,15 @@ import moment from 'moment';
// @ts-expect-error
import { calculateAuto } from './calculate_auto';
export function getBucketSize(
start: number,
end: number,
numBuckets: number = 100
) {
export function getBucketSize({
start,
end,
numBuckets = 100,
}: {
start: number;
end: number;
numBuckets?: number;
}) {
const duration = moment.duration(end - start, 'ms');
const bucketSize = Math.max(
calculateAuto.near(numBuckets, duration).asSeconds(),

View file

@ -11,7 +11,7 @@ export function getMetricsDateHistogramParams(
end: number,
metricsInterval: number
) {
const { bucketSize } = getBucketSize(start, end);
const { bucketSize } = getBucketSize({ start, end });
return {
field: '@timestamp',

View file

@ -20,6 +20,8 @@ export function getOutcomeAggregation({
return {
terms: { field: EVENT_OUTCOME },
aggs: {
// simply using the doc count to get the number of requests is not possible for transaction metrics (histograms)
// to work around this we get the number of transactions by counting the number of latency values
count: {
value_count: {
field: getTransactionDurationFieldForAggregatedTransactions(

View file

@ -40,7 +40,7 @@ export async function fetchAndTransformGcMetrics({
}) {
const { start, end, apmEventClient, config } = setup;
const { bucketSize } = getBucketSize(start, end);
const { bucketSize } = getBucketSize({ start, end });
const projection = getMetricsProjection({
setup,

View file

@ -43,7 +43,7 @@ export async function getServiceErrorGroups({
}) {
const { apmEventClient, start, end, esFilter } = setup;
const { intervalString } = getBucketSize(start, end, numBuckets);
const { intervalString } = getBucketSize({ start, end, numBuckets });
const response = await apmEventClient.search({
apm: {

View file

@ -37,7 +37,8 @@ import {
function getDateHistogramOpts(start: number, end: number) {
return {
field: '@timestamp',
fixed_interval: getBucketSize(start, end, 20).intervalString,
fixed_interval: getBucketSize({ start, end, numBuckets: 20 })
.intervalString,
min_doc_count: 0,
extended_bounds: { min: start, max: end },
};

View file

@ -1,90 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { rangeFilter } from '../../../../common/utils/range_filter';
import {
SERVICE_NAME,
TRANSACTION_NAME,
TRANSACTION_TYPE,
} from '../../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../../common/processor_event';
import { Setup, SetupTimeRange } from '../../helpers/setup_request';
import {
getSignificantTermsAgg,
formatAggregationResponse,
} from './get_significant_terms_agg';
import { SignificantTermsScoring } from './scoring_rt';
export async function getCorrelationsForRanges({
serviceName,
transactionType,
transactionName,
scoring,
gapBetweenRanges,
fieldNames,
setup,
}: {
serviceName: string | undefined;
transactionType: string | undefined;
transactionName: string | undefined;
scoring: SignificantTermsScoring;
gapBetweenRanges: number;
fieldNames: string[];
setup: Setup & SetupTimeRange;
}) {
const { start, end, esFilter, apmEventClient } = setup;
const baseFilters = [...esFilter];
if (serviceName) {
baseFilters.push({ term: { [SERVICE_NAME]: serviceName } });
}
if (transactionType) {
baseFilters.push({ term: { [TRANSACTION_TYPE]: transactionType } });
}
if (transactionName) {
baseFilters.push({ term: { [TRANSACTION_NAME]: transactionName } });
}
const diff = end - start + gapBetweenRanges;
const baseRangeStart = start - diff;
const baseRangeEnd = end - diff;
const backgroundFilters = [
...baseFilters,
{ range: rangeFilter(baseRangeStart, baseRangeEnd) },
];
const params = {
apm: { events: [ProcessorEvent.transaction] },
body: {
size: 0,
query: {
bool: { filter: [...baseFilters, { range: rangeFilter(start, end) }] },
},
aggs: getSignificantTermsAgg({
fieldNames,
backgroundFilters,
backgroundIsSuperset: false,
scoring,
}),
},
};
const response = await apmEventClient.search(params);
return {
message: `Showing significant fields between the ranges`,
firstRange: `${new Date(baseRangeStart).toISOString()} - ${new Date(
baseRangeEnd
).toISOString()}`,
lastRange: `${new Date(start).toISOString()} - ${new Date(
end
).toISOString()}`,
response: formatAggregationResponse(response.aggregations),
};
}

View file

@ -1,68 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { ESFilter } from '../../../../../../typings/elasticsearch';
import { SignificantTermsScoring } from './scoring_rt';
export function getSignificantTermsAgg({
fieldNames,
backgroundFilters,
backgroundIsSuperset = true,
scoring = 'percentage',
}: {
fieldNames: string[];
backgroundFilters: ESFilter[];
backgroundIsSuperset?: boolean;
scoring: SignificantTermsScoring;
}) {
return fieldNames.reduce((acc, fieldName) => {
return {
...acc,
[fieldName]: {
significant_terms: {
size: 10,
field: fieldName,
background_filter: { bool: { filter: backgroundFilters } },
// indicate whether background is a superset of the foreground
mutual_information: { background_is_superset: backgroundIsSuperset },
// different scorings https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-significantterms-aggregation.html#significantterms-aggregation-parameters
[scoring]: {},
min_doc_count: 5,
shard_min_doc_count: 5,
},
},
[`cardinality-${fieldName}`]: {
cardinality: { field: fieldName },
},
};
}, {} as Record<string, any>);
}
export function formatAggregationResponse(aggs?: Record<string, any>) {
if (!aggs) {
return;
}
return Object.entries(aggs).reduce((acc, [key, value]) => {
if (key.startsWith('cardinality-')) {
if (value.value > 0) {
const fieldName = key.slice(12);
acc[fieldName] = {
...acc[fieldName],
cardinality: value.value,
};
}
} else if (value.buckets.length > 0) {
acc[key] = {
...acc[key],
value,
};
}
return acc;
}, {} as Record<string, { cardinality: number; value: any }>);
}

View file

@ -1,16 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import * as t from 'io-ts';
export const scoringRt = t.union([
t.literal('jlh'),
t.literal('chi_square'),
t.literal('gnd'),
t.literal('percentage'),
]);
export type SignificantTermsScoring = t.TypeOf<typeof scoringRt>;

View file

@ -78,7 +78,7 @@ export async function getErrorRate({
timeseries: {
date_histogram: {
field: '@timestamp',
fixed_interval: getBucketSize(start, end).intervalString,
fixed_interval: getBucketSize({ start, end }).intervalString,
min_doc_count: 0,
extended_bounds: { min: start, max: end },
},

View file

@ -77,7 +77,7 @@ export async function getAnomalySeries({
return;
}
const { intervalString, bucketSize } = getBucketSize(start, end);
const { intervalString, bucketSize } = getBucketSize({ start, end });
const esResponse = await anomalySeriesFetcher({
serviceName,

View file

@ -36,7 +36,7 @@ export function timeseriesFetcher({
searchAggregatedTransactions: boolean;
}) {
const { start, end, apmEventClient } = setup;
const { intervalString } = getBucketSize(start, end);
const { intervalString } = getBucketSize({ start, end });
const filter: ESFilter[] = [
{ term: { [SERVICE_NAME]: serviceName } },

View file

@ -17,7 +17,7 @@ export async function getApmTimeseriesData(options: {
searchAggregatedTransactions: boolean;
}) {
const { start, end } = options.setup;
const { bucketSize } = getBucketSize(start, end);
const { bucketSize } = getBucketSize({ start, end });
const durationAsMinutes = (end - start) / 1000 / 60;
const timeseriesResponse = await timeseriesFetcher(options);

View file

@ -6,21 +6,19 @@
import * as t from 'io-ts';
import { rangeRt } from './default_api_types';
import { getCorrelationsForSlowTransactions } from '../lib/transaction_groups/correlations/get_correlations_for_slow_transactions';
import { getCorrelationsForRanges } from '../lib/transaction_groups/correlations/get_correlations_for_ranges';
import { scoringRt } from '../lib/transaction_groups/correlations/scoring_rt';
import { getCorrelationsForSlowTransactions } from '../lib/correlations/get_correlations_for_slow_transactions';
import { getCorrelationsForFailedTransactions } from '../lib/correlations/get_correlations_for_failed_transactions';
import { createRoute } from './create_route';
import { setupRequest } from '../lib/helpers/setup_request';
export const correlationsForSlowTransactionsRoute = createRoute({
endpoint: 'GET /api/apm/correlations/slow_durations',
endpoint: 'GET /api/apm/correlations/slow_transactions',
params: t.type({
query: t.intersection([
t.partial({
serviceName: t.string,
transactionName: t.string,
transactionType: t.string,
scoring: scoringRt,
}),
t.type({
durationPercentile: t.string,
@ -39,7 +37,6 @@ export const correlationsForSlowTransactionsRoute = createRoute({
transactionName,
durationPercentile,
fieldNames,
scoring = 'percentage',
} = context.params.query;
return getCorrelationsForSlowTransactions({
@ -48,22 +45,19 @@ export const correlationsForSlowTransactionsRoute = createRoute({
transactionName,
durationPercentile: parseInt(durationPercentile, 10),
fieldNames: fieldNames.split(','),
scoring,
setup,
});
},
});
export const correlationsForRangesRoute = createRoute({
endpoint: 'GET /api/apm/correlations/ranges',
export const correlationsForFailedTransactionsRoute = createRoute({
endpoint: 'GET /api/apm/correlations/failed_transactions',
params: t.type({
query: t.intersection([
t.partial({
serviceName: t.string,
transactionName: t.string,
transactionType: t.string,
scoring: scoringRt,
gap: t.string,
}),
t.type({
fieldNames: t.string,
@ -75,27 +69,18 @@ export const correlationsForRangesRoute = createRoute({
options: { tags: ['access:apm'] },
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
const {
serviceName,
transactionType,
transactionName,
scoring = 'percentage',
gap,
fieldNames,
} = context.params.query;
const gapBetweenRanges = parseInt(gap || '0', 10) * 3600 * 1000;
if (gapBetweenRanges < 0) {
throw new Error('gap must be 0 or positive');
}
return getCorrelationsForRanges({
return getCorrelationsForFailedTransactions({
serviceName,
transactionType,
transactionName,
scoring,
gapBetweenRanges,
fieldNames: fieldNames.split(','),
setup,
});

View file

@ -43,8 +43,8 @@ import { serviceNodesRoute } from './service_nodes';
import { tracesRoute, tracesByIdRoute } from './traces';
import { transactionByTraceIdRoute } from './transaction';
import {
correlationsForRangesRoute,
correlationsForSlowTransactionsRoute,
correlationsForFailedTransactionsRoute,
} from './correlations';
import {
transactionGroupsBreakdownRoute,
@ -129,7 +129,7 @@ const createApmApi = () => {
// Correlations
.add(correlationsForSlowTransactionsRoute)
.add(correlationsForRangesRoute)
.add(correlationsForFailedTransactionsRoute)
// APM indices
.add(apmIndexSettingsRoute)

View file

@ -1,95 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { format } from 'url';
import { PromiseReturnType } from '../../../../../plugins/observability/typings/common';
import archives_metadata from '../../../common/archives_metadata';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
export default function ApiTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const archiveName = 'apm_8.0.0';
const range = archives_metadata[archiveName];
// url parameters
const start = '2020-09-29T14:45:00.000Z';
const end = range.end;
const fieldNames =
'user.username,user.id,host.ip,user_agent.name,kubernetes.pod.uuid,url.domain,container.id,service.node.name';
describe('Ranges', () => {
const url = format({
pathname: `/api/apm/correlations/ranges`,
query: { start, end, fieldNames },
});
describe('when data is not loaded ', () => {
it('handles the empty state', async () => {
const response = await supertest.get(url);
expect(response.status).to.be(200);
expect(response.body.response).to.be(undefined);
});
});
describe('when data is loaded', () => {
let response: PromiseReturnType<typeof supertest.get>;
before(async () => {
await esArchiver.load(archiveName);
response = await supertest.get(url);
});
after(() => esArchiver.unload(archiveName));
it('returns successfully', () => {
expect(response.status).to.eql(200);
});
it('returns fields in response', () => {
expectSnapshot(Object.keys(response.body.response)).toMatchInline(`
Array [
"service.node.name",
"host.ip",
"user.id",
"user_agent.name",
"container.id",
"url.domain",
]
`);
});
it('returns cardinality for each field', () => {
const cardinalitys = Object.values(response.body.response).map(
(field: any) => field.cardinality
);
expectSnapshot(cardinalitys).toMatchInline(`
Array [
5,
6,
20,
6,
5,
4,
]
`);
});
it('returns buckets', () => {
const { buckets } = response.body.response['user.id'].value;
expectSnapshot(buckets[0]).toMatchInline(`
Object {
"bg_count": 2,
"doc_count": 7,
"key": "20",
"score": 3.5,
}
`);
});
});
});
}

View file

@ -1,115 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { format } from 'url';
import { PromiseReturnType } from '../../../../../plugins/observability/typings/common';
import archives_metadata from '../../../common/archives_metadata';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
export default function ApiTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const archiveName = 'apm_8.0.0';
const range = archives_metadata[archiveName];
// url parameters
const start = range.start;
const end = range.end;
const durationPercentile = 95;
const fieldNames =
'user.username,user.id,host.ip,user_agent.name,kubernetes.pod.uuid,url.domain,container.id,service.node.name';
// Failing: See https://github.com/elastic/kibana/issues/81264
describe('Slow durations', () => {
const url = format({
pathname: `/api/apm/correlations/slow_durations`,
query: { start, end, durationPercentile, fieldNames },
});
describe('when data is not loaded ', () => {
it('handles the empty state', async () => {
const response = await supertest.get(url);
expect(response.status).to.be(200);
expect(response.body.response).to.be(undefined);
});
});
describe('when data is loaded', () => {
before(() => esArchiver.load(archiveName));
after(() => esArchiver.unload(archiveName));
describe('making request with default args', () => {
let response: PromiseReturnType<typeof supertest.get>;
before(async () => {
response = await supertest.get(url);
it('returns successfully', () => {
expect(response.status).to.eql(200);
});
it('returns fields in response', () => {
expectSnapshot(Object.keys(response.body.response)).toMatchInline(`
Array [
"service.node.name",
"host.ip",
"user.id",
"user_agent.name",
"container.id",
"url.domain",
]
`);
});
it('returns cardinality for each field', () => {
const cardinalitys = Object.values(response.body.response).map(
(field: any) => field.cardinality
);
expectSnapshot(cardinalitys).toMatchInline(`
Array [
5,
6,
3,
5,
5,
4,
]
`);
});
it('returns buckets', () => {
const { buckets } = response.body.response['user.id'].value;
expectSnapshot(buckets[0]).toMatchInline(`
Object {
"bg_count": 32,
"doc_count": 6,
"key": "2",
"score": 0.1875,
}
`);
});
});
});
describe('making a request for each "scoring"', () => {
['percentage', 'jlh', 'chi_square', 'gnd'].map(async (scoring) => {
it(`returns response for scoring "${scoring}"`, async () => {
const response = await supertest.get(
format({
pathname: `/api/apm/correlations/slow_durations`,
query: { start, end, durationPercentile, fieldNames, scoring },
})
);
expect(response.status).to.be(200);
});
});
});
});
});
}

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { format } from 'url';
import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/createCallApmApi';
import archives_metadata from '../../../common/archives_metadata';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
export default function ApiTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const archiveName = 'apm_8.0.0';
const range = archives_metadata[archiveName];
describe('Slow durations', () => {
const url = format({
pathname: `/api/apm/correlations/slow_transactions`,
query: {
start: range.start,
end: range.end,
durationPercentile: 95,
fieldNames:
'user.username,user.id,host.ip,user_agent.name,kubernetes.pod.uuid,url.domain,container.id,service.node.name',
},
});
describe('when data is not loaded', () => {
it('handles the empty state', async () => {
const response = await supertest.get(url);
expect(response.status).to.be(200);
expect(response.body.response).to.be(undefined);
});
});
describe('when data is loaded', () => {
before(() => esArchiver.load(archiveName));
after(() => esArchiver.unload(archiveName));
describe('making request with default args', () => {
type ResponseBody = APIReturnType<'GET /api/apm/correlations/slow_transactions'>;
let response: {
status: number;
body: NonNullable<ResponseBody>;
};
before(async () => {
response = await supertest.get(url);
});
it('returns successfully', () => {
expect(response.status).to.eql(200);
});
it('returns significant terms', () => {
expectSnapshot(response.body?.significantTerms?.map((term) => term.fieldName))
.toMatchInline(`
Array [
"host.ip",
"service.node.name",
"container.id",
"url.domain",
"user_agent.name",
"user.id",
"host.ip",
"service.node.name",
"container.id",
"user.id",
]
`);
});
it('returns a timeseries per term', () => {
// @ts-ignore
expectSnapshot(response.body?.significantTerms[0].timeseries.length).toMatchInline(`31`);
});
it('returns a distribution per term', () => {
// @ts-ignore
expectSnapshot(response.body?.significantTerms[0].distribution.length).toMatchInline(
`11`
);
});
it('returns overall timeseries', () => {
// @ts-ignore
expectSnapshot(response.body?.overall.timeseries.length).toMatchInline(`31`);
});
it('returns overall distribution', () => {
// @ts-ignore
expectSnapshot(response.body?.overall.distribution.length).toMatchInline(`11`);
});
});
});
});
}

View file

@ -59,8 +59,7 @@ export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderCont
});
describe('Correlations', function () {
loadTestFile(require.resolve('./correlations/slow_durations'));
loadTestFile(require.resolve('./correlations/ranges'));
loadTestFile(require.resolve('./correlations/slow_transactions'));
});
});
}

View file

@ -99,9 +99,8 @@ export default function ApiTest({ getService }: FtrProviderContext) {
}
`);
expectSnapshot(
firstItem.occurrences.timeseries.filter(({ y }: any) => y > 0).length
).toMatchInline(`7`);
const visibleDataPoints = firstItem.occurrences.timeseries.filter(({ y }: any) => y > 0);
expectSnapshot(visibleDataPoints.length).toMatchInline(`7`);
});
it('sorts items in the correct order', async () => {

View file

@ -354,6 +354,7 @@ interface AggregationResponsePart<TAggregationOptionsMap extends AggregationOpti
bg_count: number;
buckets: Array<
{
score: number;
bg_count: number;
doc_count: number;
key: string | number;