[APM] Handle other values popup when correlated value is not in top 10 (#118069) (#120680)

* [ML] Add stats for field value not in top 10

* [ML] Fix tests

* [ML] Add message

* [ML] Reverse label, remove Fragment, switch to i18n

* Fix sample shard size import, subdued text

* Fix sample shard size import, subdued text

* Move routes after refactor

* Add loading spinner

* Fix sampler shard size

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Quynh Nguyen <43350163+qn895@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2021-12-07 16:55:04 -05:00 committed by GitHub
parent cb85623b28
commit 04fee5b82b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 414 additions and 264 deletions

View file

@ -8,9 +8,7 @@
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { CorrelationsParams } from './types';
export interface FieldStatsCommonRequestParams extends CorrelationsParams {
samplerShardSize: number;
}
export type FieldStatsCommonRequestParams = CorrelationsParams;
export interface Field {
fieldName: string;
@ -55,3 +53,5 @@ export type FieldStats =
| NumericFieldStats
| KeywordFieldStats
| BooleanFieldStats;
export type FieldValueFieldStats = TopValuesStats;

View file

@ -11,14 +11,11 @@ import {
EuiFlexItem,
EuiPopover,
EuiPopoverTitle,
EuiSpacer,
EuiText,
EuiTitle,
EuiToolTip,
} from '@elastic/eui';
import React, { Fragment, useState } from 'react';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { FieldStats } from '../../../../../common/correlations/field_stats_types';
import { OnAddFilter, TopValues } from './top_values';
import { useTheme } from '../../../../hooks/use_theme';
@ -97,27 +94,11 @@ export function CorrelationsContextPopover({
</h5>
</EuiTitle>
{infoIsOpen ? (
<>
<TopValues
topValueStats={topValueStats}
onAddFilter={onAddFilter}
fieldValue={fieldValue}
/>
{topValueStats.topValuesSampleSize !== undefined && (
<Fragment>
<EuiSpacer size="s" />
<EuiText size="xs">
<FormattedMessage
id="xpack.apm.correlations.fieldContextPopover.calculatedFromSampleDescription"
defaultMessage="Calculated from sample of {sampleSize} documents"
values={{
sampleSize: topValueStats.topValuesSampleSize,
}}
/>
</EuiText>
</Fragment>
)}
</>
<TopValues
topValueStats={topValueStats}
onAddFilter={onAddFilter}
fieldValue={fieldValue}
/>
) : null}
</EuiPopover>
);

View file

@ -12,11 +12,21 @@ import {
EuiProgress,
EuiSpacer,
EuiToolTip,
EuiText,
EuiHorizontalRule,
EuiLoadingSpinner,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FieldStats } from '../../../../../common/correlations/field_stats_types';
import numeral from '@elastic/numeral';
import { FormattedMessage } from '@kbn/i18n-react';
import {
FieldStats,
TopValueBucket,
} from '../../../../../common/correlations/field_stats_types';
import { asPercent } from '../../../../../common/utils/formatters';
import { useTheme } from '../../../../hooks/use_theme';
import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
import { useFetchParams } from '../use_fetch_params';
export type OnAddFilter = ({
fieldName,
@ -28,23 +38,179 @@ export type OnAddFilter = ({
include: boolean;
}) => void;
interface Props {
interface TopValueProps {
progressBarMax: number;
barColor: string;
value: TopValueBucket;
isHighlighted: boolean;
fieldName: string;
onAddFilter?: OnAddFilter;
valueText?: string;
reverseLabel?: boolean;
}
export function TopValue({
progressBarMax,
barColor,
value,
isHighlighted,
fieldName,
onAddFilter,
valueText,
reverseLabel = false,
}: TopValueProps) {
const theme = useTheme();
return (
<EuiFlexGroup gutterSize="xs" alignItems="center" key={value.key}>
<EuiFlexItem
data-test-subj="apmCorrelationsContextPopoverTopValueBar"
className="eui-textTruncate"
style={reverseLabel ? { flexFlow: 'column-reverse' } : undefined}
>
<EuiProgress
value={value.doc_count}
max={progressBarMax}
color={barColor}
size="s"
label={
<EuiToolTip content={value.key}>
<span>{value.key}</span>
</EuiToolTip>
}
className="eui-textTruncate"
aria-label={value.key.toString()}
valueText={valueText}
labelProps={
isHighlighted
? {
style: { fontWeight: 'bold' },
}
: undefined
}
/>
</EuiFlexItem>
{fieldName !== undefined &&
value.key !== undefined &&
onAddFilter !== undefined ? (
<>
<EuiButtonIcon
iconSize="s"
iconType="plusInCircle"
onClick={() => {
onAddFilter({
fieldName,
fieldValue:
typeof value.key === 'number'
? value.key.toString()
: value.key,
include: true,
});
}}
aria-label={i18n.translate(
'xpack.apm.correlations.fieldContextPopover.addFilterAriaLabel',
{
defaultMessage: 'Filter for {fieldName}: "{value}"',
values: { fieldName, value: value.key },
}
)}
data-test-subj={`apmFieldContextTopValuesAddFilterButton-${value.key}-${value.key}`}
style={{
minHeight: 'auto',
width: theme.eui.euiSizeL,
paddingRight: 2,
paddingLeft: 2,
paddingTop: 0,
paddingBottom: 0,
}}
/>
<EuiButtonIcon
iconSize="s"
iconType="minusInCircle"
onClick={() => {
onAddFilter({
fieldName,
fieldValue:
typeof value.key === 'number'
? value.key.toString()
: value.key,
include: false,
});
}}
aria-label={i18n.translate(
'xpack.apm.correlations.fieldContextPopover.removeFilterAriaLabel',
{
defaultMessage: 'Filter out {fieldName}: "{value}"',
values: { fieldName, value: value.key },
}
)}
data-test-subj={`apmFieldContextTopValuesExcludeFilterButton-${value.key}-${value.key}`}
style={{
minHeight: 'auto',
width: theme.eui.euiSizeL,
paddingTop: 0,
paddingBottom: 0,
paddingRight: 2,
paddingLeft: 2,
}}
/>
</>
) : null}
</EuiFlexGroup>
);
}
interface TopValuesProps {
topValueStats: FieldStats;
compressed?: boolean;
onAddFilter?: OnAddFilter;
fieldValue?: string | number;
}
export function TopValues({ topValueStats, onAddFilter, fieldValue }: Props) {
export function TopValues({
topValueStats,
onAddFilter,
fieldValue,
}: TopValuesProps) {
const { topValues, topValuesSampleSize, count, fieldName } = topValueStats;
const theme = useTheme();
if (!Array.isArray(topValues) || topValues.length === 0) return null;
const idxToHighlight = Array.isArray(topValues)
? topValues.findIndex((value) => value.key === fieldValue)
: null;
const params = useFetchParams();
const { data: fieldValueStats, status } = useFetcher(
(callApmApi) => {
if (
idxToHighlight === -1 &&
fieldName !== undefined &&
fieldValue !== undefined
) {
return callApmApi({
endpoint: 'GET /internal/apm/correlations/field_value_stats',
params: {
query: {
...params,
fieldName,
fieldValue,
},
},
});
}
},
[params, fieldName, fieldValue, idxToHighlight]
);
if (
!Array.isArray(topValues) ||
topValues?.length === 0 ||
fieldValue === undefined
)
return null;
const sampledSize =
typeof topValuesSampleSize === 'string'
? parseInt(topValuesSampleSize, 10)
: topValuesSampleSize;
const progressBarMax = sampledSize ?? count;
return (
<div
@ -61,109 +227,79 @@ export function TopValues({ topValueStats, onAddFilter, fieldValue }: Props) {
const barColor = isHighlighted ? 'accent' : 'primary';
const valueText =
progressBarMax !== undefined
? asPercent(value.doc_count, progressBarMax)
? numeral(value.doc_count / progressBarMax).format('0.0%') // asPercent(value.doc_count, progressBarMax)
: undefined;
return (
<>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="xs" alignItems="center" key={value.key}>
<EuiFlexItem
data-test-subj="apmCorrelationsContextPopoverTopValueBar"
className="eui-textTruncate"
>
<EuiProgress
value={value.doc_count}
max={progressBarMax}
color={barColor}
size="s"
label={
<EuiToolTip content={value.key}>
<span>{value.key}</span>
</EuiToolTip>
}
className="eui-textTruncate"
aria-label={value.key.toString()}
valueText={valueText}
labelProps={
isHighlighted
? {
style: { fontWeight: 'bold' },
}
: undefined
}
/>
</EuiFlexItem>
{fieldName !== undefined &&
value.key !== undefined &&
onAddFilter !== undefined ? (
<>
<EuiButtonIcon
iconSize="s"
iconType="plusInCircle"
onClick={() => {
onAddFilter({
fieldName,
fieldValue:
typeof value.key === 'number'
? value.key.toString()
: value.key,
include: true,
});
}}
aria-label={i18n.translate(
'xpack.apm.correlations.fieldContextPopover.addFilterAriaLabel',
{
defaultMessage: 'Filter for {fieldName}: "{value}"',
values: { fieldName, value: value.key },
}
)}
data-test-subj={`apmFieldContextTopValuesAddFilterButton-${value.key}-${value.key}`}
style={{
minHeight: 'auto',
width: theme.eui.euiSizeL,
paddingRight: 2,
paddingLeft: 2,
paddingTop: 0,
paddingBottom: 0,
}}
/>
<EuiButtonIcon
iconSize="s"
iconType="minusInCircle"
onClick={() => {
onAddFilter({
fieldName,
fieldValue:
typeof value.key === 'number'
? value.key.toString()
: value.key,
include: false,
});
}}
aria-label={i18n.translate(
'xpack.apm.correlations.fieldContextPopover.removeFilterAriaLabel',
{
defaultMessage: 'Filter out {fieldName}: "{value}"',
values: { fieldName, value: value.key },
}
)}
data-test-subj={`apmFieldContextTopValuesExcludeFilterButton-${value.key}-${value.key}`}
style={{
minHeight: 'auto',
width: theme.eui.euiSizeL,
paddingTop: 0,
paddingBottom: 0,
paddingRight: 2,
paddingLeft: 2,
}}
/>
</>
) : null}
</EuiFlexGroup>
<TopValue
value={value}
barColor={barColor}
valueText={valueText}
onAddFilter={onAddFilter}
progressBarMax={progressBarMax}
isHighlighted={isHighlighted}
fieldName={fieldName}
/>
</>
);
})}
{idxToHighlight === -1 && (
<>
<EuiHorizontalRule margin="s" />
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.apm.correlations.fieldContextPopover.notTopTenValueMessage"
defaultMessage="Selected term is not in the top 10"
/>
</EuiText>
<EuiSpacer size="s" />
{status === FETCH_STATUS.SUCCESS &&
Array.isArray(fieldValueStats?.topValues) ? (
fieldValueStats?.topValues.map((value) => {
const valueText =
progressBarMax !== undefined
? asPercent(value.doc_count, progressBarMax)
: undefined;
return (
<TopValue
value={value}
barColor={'accent'}
valueText={valueText}
onAddFilter={onAddFilter}
progressBarMax={progressBarMax}
isHighlighted={true}
fieldName={fieldName}
reverseLabel={true}
/>
);
})
) : (
<EuiText textAlign="center">
<EuiLoadingSpinner />
</EuiText>
)}
</>
)}
{topValueStats.topValuesSampleSize !== undefined && (
<>
<EuiSpacer size="s" />
<EuiText size="xs">
{i18n.translate(
'xpack.apm.correlations.fieldContextPopover.calculatedFromSampleDescription',
{
defaultMessage:
'Calculated from sample of {sampleSize} documents',
values: { sampleSize: topValueStats.topValuesSampleSize },
}
)}
</EuiText>
</>
)}
</div>
);
}

View file

@ -7,8 +7,6 @@
import { ElasticsearchClient } from 'kibana/server';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { buildSamplerAggregation } from '../../utils/field_stats_utils';
import { FieldValuePair } from '../../../../../common/correlations/types';
import {
FieldStatsCommonRequestParams,
@ -25,7 +23,7 @@ export const getBooleanFieldStatsRequest = (
): estypes.SearchRequest => {
const query = getQueryWithParams({ params, termFilters });
const { index, samplerShardSize } = params;
const { index } = params;
const size = 0;
const aggs: Aggs = {
@ -42,14 +40,13 @@ export const getBooleanFieldStatsRequest = (
const searchBody = {
query,
aggs: {
sample: buildSamplerAggregation(aggs, samplerShardSize),
},
aggs,
};
return {
index,
size,
track_total_hits: false,
body: searchBody,
};
};
@ -67,19 +64,17 @@ export const fetchBooleanFieldStats = async (
);
const { body } = await esClient.search(request);
const aggregations = body.aggregations as {
sample: {
sampled_value_count: estypes.AggregationsFiltersBucketItemKeys;
sampled_values: estypes.AggregationsTermsAggregate<TopValueBucket>;
};
sampled_value_count: estypes.AggregationsFiltersBucketItemKeys;
sampled_values: estypes.AggregationsTermsAggregate<TopValueBucket>;
};
const stats: BooleanFieldStats = {
fieldName: field.fieldName,
count: aggregations?.sample.sampled_value_count.doc_count ?? 0,
count: aggregations?.sampled_value_count.doc_count ?? 0,
};
const valueBuckets: TopValueBucket[] =
aggregations?.sample.sampled_values?.buckets ?? [];
aggregations?.sampled_values?.buckets ?? [];
valueBuckets.forEach((bucket) => {
stats[`${bucket.key.toString()}Count`] = bucket.doc_count;
});

View file

@ -20,7 +20,6 @@ const params = {
includeFrozen: false,
environment: ENVIRONMENT_ALL.value,
kuery: '',
samplerShardSize: 5000,
};
export const getExpectedQuery = (aggs: any) => {
@ -46,6 +45,7 @@ export const getExpectedQuery = (aggs: any) => {
},
index: 'apm-*',
size: 0,
track_total_hits: false,
};
};
@ -55,28 +55,16 @@ describe('field_stats', () => {
const req = getNumericFieldStatsRequest(params, 'url.path');
const expectedAggs = {
sample: {
aggs: {
sampled_field_stats: {
aggs: { actual_stats: { stats: { field: 'url.path' } } },
filter: { exists: { field: 'url.path' } },
},
sampled_percentiles: {
percentiles: {
field: 'url.path',
keyed: false,
percents: [50],
},
},
sampled_top: {
terms: {
field: 'url.path',
order: { _count: 'desc' },
size: 10,
},
},
sampled_field_stats: {
aggs: { actual_stats: { stats: { field: 'url.path' } } },
filter: { exists: { field: 'url.path' } },
},
sampled_top: {
terms: {
field: 'url.path',
order: { _count: 'desc' },
size: 10,
},
sampler: { shard_size: 5000 },
},
};
expect(req).toEqual(getExpectedQuery(expectedAggs));
@ -87,13 +75,8 @@ describe('field_stats', () => {
const req = getKeywordFieldStatsRequest(params, 'url.path');
const expectedAggs = {
sample: {
sampler: { shard_size: 5000 },
aggs: {
sampled_top: {
terms: { field: 'url.path', size: 10, order: { _count: 'desc' } },
},
},
sampled_top: {
terms: { field: 'url.path', size: 10 },
},
};
expect(req).toEqual(getExpectedQuery(expectedAggs));
@ -104,15 +87,10 @@ describe('field_stats', () => {
const req = getBooleanFieldStatsRequest(params, 'url.path');
const expectedAggs = {
sample: {
sampler: { shard_size: 5000 },
aggs: {
sampled_value_count: {
filter: { exists: { field: 'url.path' } },
},
sampled_values: { terms: { field: 'url.path', size: 2 } },
},
sampled_value_count: {
filter: { exists: { field: 'url.path' } },
},
sampled_values: { terms: { field: 'url.path', size: 2 } },
};
expect(req).toEqual(getExpectedQuery(expectedAggs));
});

View file

@ -0,0 +1,76 @@
/*
* 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 { ElasticsearchClient } from 'kibana/server';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { FieldValuePair } from '../../../../../common/correlations/types';
import {
FieldStatsCommonRequestParams,
FieldValueFieldStats,
Aggs,
TopValueBucket,
} from '../../../../../common/correlations/field_stats_types';
import { getQueryWithParams } from '../get_query_with_params';
export const getFieldValueFieldStatsRequest = (
params: FieldStatsCommonRequestParams,
field?: FieldValuePair
): estypes.SearchRequest => {
const query = getQueryWithParams({ params });
const { index } = params;
const size = 0;
const aggs: Aggs = {
filtered_count: {
filter: {
term: {
[`${field?.fieldName}`]: field?.fieldValue,
},
},
},
};
const searchBody = {
query,
aggs,
};
return {
index,
size,
track_total_hits: false,
body: searchBody,
};
};
export const fetchFieldValueFieldStats = async (
esClient: ElasticsearchClient,
params: FieldStatsCommonRequestParams,
field: FieldValuePair
): Promise<FieldValueFieldStats> => {
const request = getFieldValueFieldStatsRequest(params, field);
const { body } = await esClient.search(request);
const aggregations = body.aggregations as {
filtered_count: estypes.AggregationsFiltersBucketItemKeys;
};
const topValues: TopValueBucket[] = [
{
key: field.fieldValue,
doc_count: aggregations.filtered_count.doc_count,
},
];
const stats = {
fieldName: field.fieldName,
topValues,
topValuesSampleSize: aggregations.filtered_count.doc_count ?? 0,
};
return stats;
};

View file

@ -8,10 +8,7 @@
import { ElasticsearchClient } from 'kibana/server';
import { chunk } from 'lodash';
import { ES_FIELD_TYPES } from '@kbn/field-types';
import {
FieldValuePair,
CorrelationsParams,
} from '../../../../../common/correlations/types';
import { FieldValuePair } from '../../../../../common/correlations/types';
import {
FieldStats,
FieldStatsCommonRequestParams,
@ -23,7 +20,7 @@ import { fetchBooleanFieldStats } from './get_boolean_field_stats';
export const fetchFieldsStats = async (
esClient: ElasticsearchClient,
params: CorrelationsParams,
fieldStatsParams: FieldStatsCommonRequestParams,
fieldsToSample: string[],
termFilters?: FieldValuePair[]
): Promise<{ stats: FieldStats[]; errors: any[] }> => {
@ -33,14 +30,10 @@ export const fetchFieldsStats = async (
if (fieldsToSample.length === 0) return { stats, errors };
const respMapping = await esClient.fieldCaps({
...getRequestBase(params),
...getRequestBase(fieldStatsParams),
fields: fieldsToSample,
});
const fieldStatsParams: FieldStatsCommonRequestParams = {
...params,
samplerShardSize: 5000,
};
const fieldStatsPromises = Object.entries(respMapping.body.fields)
.map(([key, value], idx) => {
const field: FieldValuePair = { fieldName: key, fieldValue: '' };

View file

@ -14,7 +14,6 @@ import {
Aggs,
TopValueBucket,
} from '../../../../../common/correlations/field_stats_types';
import { buildSamplerAggregation } from '../../utils/field_stats_utils';
import { getQueryWithParams } from '../get_query_with_params';
export const getKeywordFieldStatsRequest = (
@ -24,7 +23,7 @@ export const getKeywordFieldStatsRequest = (
): estypes.SearchRequest => {
const query = getQueryWithParams({ params, termFilters });
const { index, samplerShardSize } = params;
const { index } = params;
const size = 0;
const aggs: Aggs = {
@ -32,23 +31,19 @@ export const getKeywordFieldStatsRequest = (
terms: {
field: fieldName,
size: 10,
order: {
_count: 'desc',
},
},
},
};
const searchBody = {
query,
aggs: {
sample: buildSamplerAggregation(aggs, samplerShardSize),
},
aggs,
};
return {
index,
size,
track_total_hits: false,
body: searchBody,
};
};
@ -66,19 +61,16 @@ export const fetchKeywordFieldStats = async (
);
const { body } = await esClient.search(request);
const aggregations = body.aggregations as {
sample: {
sampled_top: estypes.AggregationsTermsAggregate<TopValueBucket>;
};
sampled_top: estypes.AggregationsTermsAggregate<TopValueBucket>;
};
const topValues: TopValueBucket[] =
aggregations?.sample.sampled_top?.buckets ?? [];
const topValues: TopValueBucket[] = aggregations?.sampled_top?.buckets ?? [];
const stats = {
fieldName: field.fieldName,
topValues,
topValuesSampleSize: topValues.reduce(
(acc, curr) => acc + curr.doc_count,
aggregations.sample.sampled_top?.sum_other_doc_count ?? 0
aggregations.sampled_top?.sum_other_doc_count ?? 0
),
};

View file

@ -6,7 +6,7 @@
*/
import { ElasticsearchClient } from 'kibana/server';
import { find, get } from 'lodash';
import { get } from 'lodash';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
NumericFieldStats,
@ -16,10 +16,6 @@ import {
} from '../../../../../common/correlations/field_stats_types';
import { FieldValuePair } from '../../../../../common/correlations/types';
import { getQueryWithParams } from '../get_query_with_params';
import { buildSamplerAggregation } from '../../utils/field_stats_utils';
// Only need 50th percentile for the median
const PERCENTILES = [50];
export const getNumericFieldStatsRequest = (
params: FieldStatsCommonRequestParams,
@ -29,9 +25,8 @@ export const getNumericFieldStatsRequest = (
const query = getQueryWithParams({ params, termFilters });
const size = 0;
const { index, samplerShardSize } = params;
const { index } = params;
const percents = PERCENTILES;
const aggs: Aggs = {
sampled_field_stats: {
filter: { exists: { field: fieldName } },
@ -41,13 +36,6 @@ export const getNumericFieldStatsRequest = (
},
},
},
sampled_percentiles: {
percentiles: {
field: fieldName,
percents,
keyed: false,
},
},
sampled_top: {
terms: {
field: fieldName,
@ -61,14 +49,13 @@ export const getNumericFieldStatsRequest = (
const searchBody = {
query,
aggs: {
sample: buildSamplerAggregation(aggs, samplerShardSize),
},
aggs,
};
return {
index,
size,
track_total_hits: false,
body: searchBody,
};
};
@ -87,19 +74,15 @@ export const fetchNumericFieldStats = async (
const { body } = await esClient.search(request);
const aggregations = body.aggregations as {
sample: {
sampled_top: estypes.AggregationsTermsAggregate<TopValueBucket>;
sampled_percentiles: estypes.AggregationsHdrPercentilesAggregate;
sampled_field_stats: {
doc_count: number;
actual_stats: estypes.AggregationsStatsAggregate;
};
sampled_top: estypes.AggregationsTermsAggregate<TopValueBucket>;
sampled_field_stats: {
doc_count: number;
actual_stats: estypes.AggregationsStatsAggregate;
};
};
const docCount = aggregations?.sample.sampled_field_stats?.doc_count ?? 0;
const fieldStatsResp =
aggregations?.sample.sampled_field_stats?.actual_stats ?? {};
const topValues = aggregations?.sample.sampled_top?.buckets ?? [];
const docCount = aggregations?.sampled_field_stats?.doc_count ?? 0;
const fieldStatsResp = aggregations?.sampled_field_stats?.actual_stats ?? {};
const topValues = aggregations?.sampled_top?.buckets ?? [];
const stats: NumericFieldStats = {
fieldName: field.fieldName,
@ -110,20 +93,9 @@ export const fetchNumericFieldStats = async (
topValues,
topValuesSampleSize: topValues.reduce(
(acc: number, curr: TopValueBucket) => acc + curr.doc_count,
aggregations.sample.sampled_top?.sum_other_doc_count ?? 0
aggregations.sampled_top?.sum_other_doc_count ?? 0
),
};
if (stats.count !== undefined && stats.count > 0) {
const percentiles = aggregations?.sample.sampled_percentiles.values ?? [];
const medianPercentile: { value: number; key: number } | undefined = find(
percentiles,
{
key: 50,
}
);
stats.median = medianPercentile !== undefined ? medianPercentile!.value : 0;
}
return stats;
};

View file

@ -16,3 +16,4 @@ export { fetchTransactionDurationCorrelation } from './query_correlation';
export { fetchTransactionDurationCorrelationWithHistogram } from './query_correlation_with_histogram';
export { fetchTransactionDurationHistogramRangeSteps } from './query_histogram_range_steps';
export { fetchTransactionDurationRanges } from './query_ranges';
export { fetchFieldValueFieldStats } from './field_stats/get_field_value_stats';

View file

@ -19,6 +19,7 @@ import {
fetchSignificantCorrelations,
fetchTransactionDurationFieldCandidates,
fetchTransactionDurationFieldValuePairs,
fetchFieldValueFieldStats,
} from './queries';
import { fetchFieldsStats } from './queries/field_stats/get_fields_stats';
@ -77,12 +78,12 @@ const fieldStatsRoute = createApmServerRoute({
transactionName: t.string,
transactionType: t.string,
}),
environmentRt,
kueryRt,
rangeRt,
t.type({
fieldsToSample: t.array(t.string),
}),
environmentRt,
kueryRt,
rangeRt,
]),
}),
options: { tags: ['access:apm'] },
@ -112,6 +113,51 @@ const fieldStatsRoute = createApmServerRoute({
},
});
const fieldValueStatsRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/correlations/field_value_stats',
params: t.type({
query: t.intersection([
t.partial({
serviceName: t.string,
transactionName: t.string,
transactionType: t.string,
}),
environmentRt,
kueryRt,
rangeRt,
t.type({
fieldName: t.string,
fieldValue: t.union([t.string, t.number]),
}),
]),
}),
options: { tags: ['access:apm'] },
handler: async (resources) => {
const { context } = resources;
if (!isActivePlatinumLicense(context.licensing.license)) {
throw Boom.forbidden(INVALID_LICENSE);
}
const { indices } = await setupRequest(resources);
const esClient = resources.context.core.elasticsearch.client.asCurrentUser;
const { fieldName, fieldValue, ...params } = resources.params.query;
return withApmSpan(
'get_correlations_field_value_stats',
async () =>
await fetchFieldValueFieldStats(
esClient,
{
...params,
index: indices.transaction,
},
{ fieldName, fieldValue }
)
);
},
});
const fieldValuePairsRoute = createApmServerRoute({
endpoint: 'POST /internal/apm/correlations/field_value_pairs',
params: t.type({
@ -252,5 +298,6 @@ export const correlationsRouteRepository = createApmServerRouteRepository()
.add(pValuesRoute)
.add(fieldCandidatesRoute)
.add(fieldStatsRoute)
.add(fieldValueStatsRoute)
.add(fieldValuePairsRoute)
.add(significantCorrelationsRoute);

View file

@ -5,8 +5,6 @@
* 2.0.
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
/*
* Contains utility functions for building and processing queries.
*/
@ -38,22 +36,3 @@ export function buildBaseFilterCriteria(
return filterCriteria;
}
// Wraps the supplied aggregations in a sampler aggregation.
// A supplied samplerShardSize (the shard_size parameter of the sampler aggregation)
// of less than 1 indicates no sampling, and the aggs are returned as-is.
export function buildSamplerAggregation(
aggs: any,
samplerShardSize: number
): estypes.AggregationsAggregationContainer {
if (samplerShardSize < 1) {
return aggs;
}
return {
sampler: {
shard_size: samplerShardSize,
},
aggs,
};
}