mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
* [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:
parent
cb85623b28
commit
04fee5b82b
12 changed files with 414 additions and 264 deletions
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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: '' };
|
||||
|
|
|
@ -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
|
||||
),
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue