mirror of https://github.com/grafana/grafana.git
Elasticsearch: Support extended stats and percentiles in terms order by (#28910)
Adds support to the terms aggregation for ordering by percentiles and extended stats. Closes #5148 Co-authored-by: Giordano Ricci <grdnricci@gmail.com> Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>
This commit is contained in:
parent
b32c4f34cd
commit
5088e2044a
File diff suppressed because it is too large
Load Diff
|
|
@ -2,6 +2,7 @@ package elasticsearch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
|
|
@ -240,15 +241,27 @@ func addTermsAgg(aggBuilder es.AggBuilder, bucketAgg *BucketAgg, metrics []*Metr
|
||||||
}
|
}
|
||||||
|
|
||||||
if orderBy, err := bucketAgg.Settings.Get("orderBy").String(); err == nil {
|
if orderBy, err := bucketAgg.Settings.Get("orderBy").String(); err == nil {
|
||||||
a.Order[orderBy] = bucketAgg.Settings.Get("order").MustString("desc")
|
/*
|
||||||
|
The format for extended stats and percentiles is {metricId}[bucket_path]
|
||||||
|
for everything else it's just {metricId}, _count, _term, or _key
|
||||||
|
*/
|
||||||
|
metricIdRegex := regexp.MustCompile(`^(\d+)`)
|
||||||
|
metricId := metricIdRegex.FindString(orderBy)
|
||||||
|
|
||||||
if _, err := strconv.Atoi(orderBy); err == nil {
|
if len(metricId) > 0 {
|
||||||
for _, m := range metrics {
|
for _, m := range metrics {
|
||||||
if m.ID == orderBy {
|
if m.ID == metricId {
|
||||||
b.Metric(m.ID, m.Type, m.Field, nil)
|
if m.Type == "count" {
|
||||||
|
a.Order["_count"] = bucketAgg.Settings.Get("order").MustString("desc")
|
||||||
|
} else {
|
||||||
|
a.Order[orderBy] = bucketAgg.Settings.Get("order").MustString("desc")
|
||||||
|
b.Metric(m.ID, m.Type, m.Field, nil)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
a.Order[orderBy] = bucketAgg.Settings.Get("order").MustString("desc")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,80 @@ func TestExecuteTimeSeriesQuery(t *testing.T) {
|
||||||
So(avgAgg.Aggregation.Type, ShouldEqual, "avg")
|
So(avgAgg.Aggregation.Type, ShouldEqual, "avg")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Convey("With term agg and order by count metric agg", func() {
|
||||||
|
c := newFakeClient(5)
|
||||||
|
_, err := executeTsdbQuery(c, `{
|
||||||
|
"timeField": "@timestamp",
|
||||||
|
"bucketAggs": [
|
||||||
|
{
|
||||||
|
"type": "terms",
|
||||||
|
"field": "@host",
|
||||||
|
"id": "2",
|
||||||
|
"settings": { "size": "5", "order": "asc", "orderBy": "1" }
|
||||||
|
},
|
||||||
|
{ "type": "date_histogram", "field": "@timestamp", "id": "3" }
|
||||||
|
],
|
||||||
|
"metrics": [
|
||||||
|
{"type": "count", "id": "1" }
|
||||||
|
]
|
||||||
|
}`, from, to, 15*time.Second)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
sr := c.multisearchRequests[0].Requests[0]
|
||||||
|
|
||||||
|
termsAgg := sr.Aggs[0].Aggregation.Aggregation.(*es.TermsAggregation)
|
||||||
|
So(termsAgg.Order["_count"], ShouldEqual, "asc")
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("With term agg and order by percentiles agg", func() {
|
||||||
|
c := newFakeClient(5)
|
||||||
|
_, err := executeTsdbQuery(c, `{
|
||||||
|
"timeField": "@timestamp",
|
||||||
|
"bucketAggs": [
|
||||||
|
{
|
||||||
|
"type": "terms",
|
||||||
|
"field": "@host",
|
||||||
|
"id": "2",
|
||||||
|
"settings": { "size": "5", "order": "asc", "orderBy": "1[95.0]" }
|
||||||
|
},
|
||||||
|
{ "type": "date_histogram", "field": "@timestamp", "id": "3" }
|
||||||
|
],
|
||||||
|
"metrics": [
|
||||||
|
{"type": "percentiles", "field": "@value", "id": "1", "settings": { "percents": ["95","99"] } }
|
||||||
|
]
|
||||||
|
}`, from, to, 15*time.Second)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
sr := c.multisearchRequests[0].Requests[0]
|
||||||
|
|
||||||
|
orderByAgg := sr.Aggs[0].Aggregation.Aggs[0]
|
||||||
|
So(orderByAgg.Key, ShouldEqual, "1")
|
||||||
|
So(orderByAgg.Aggregation.Type, ShouldEqual, "percentiles")
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("With term agg and order by extended stats agg", func() {
|
||||||
|
c := newFakeClient(5)
|
||||||
|
_, err := executeTsdbQuery(c, `{
|
||||||
|
"timeField": "@timestamp",
|
||||||
|
"bucketAggs": [
|
||||||
|
{
|
||||||
|
"type": "terms",
|
||||||
|
"field": "@host",
|
||||||
|
"id": "2",
|
||||||
|
"settings": { "size": "5", "order": "asc", "orderBy": "1[std_deviation]" }
|
||||||
|
},
|
||||||
|
{ "type": "date_histogram", "field": "@timestamp", "id": "3" }
|
||||||
|
],
|
||||||
|
"metrics": [
|
||||||
|
{"type": "extended_stats", "field": "@value", "id": "1", "meta": { "std_deviation": true } }
|
||||||
|
]
|
||||||
|
}`, from, to, 15*time.Second)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
sr := c.multisearchRequests[0].Requests[0]
|
||||||
|
|
||||||
|
orderByAgg := sr.Aggs[0].Aggregation.Aggs[0]
|
||||||
|
So(orderByAgg.Key, ShouldEqual, "1")
|
||||||
|
So(orderByAgg.Aggregation.Type, ShouldEqual, "extended_stats")
|
||||||
|
})
|
||||||
|
|
||||||
Convey("With term agg and order by term", func() {
|
Convey("With term agg and order by term", func() {
|
||||||
c := newFakeClient(5)
|
c := newFakeClient(5)
|
||||||
_, err := executeTsdbQuery(c, `{
|
_, err := executeTsdbQuery(c, `{
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,16 @@ import { useDispatch } from '../../../../hooks/useStatelessReducer';
|
||||||
import { SettingsEditorContainer } from '../../SettingsEditorContainer';
|
import { SettingsEditorContainer } from '../../SettingsEditorContainer';
|
||||||
import { changeBucketAggregationSetting } from '../state/actions';
|
import { changeBucketAggregationSetting } from '../state/actions';
|
||||||
import { BucketAggregation } from '../aggregations';
|
import { BucketAggregation } from '../aggregations';
|
||||||
import { bucketAggregationConfig, intervalOptions, orderByOptions, orderOptions, sizeOptions } from '../utils';
|
import {
|
||||||
|
bucketAggregationConfig,
|
||||||
|
createOrderByOptionsFromMetrics,
|
||||||
|
intervalOptions,
|
||||||
|
orderOptions,
|
||||||
|
sizeOptions,
|
||||||
|
} from '../utils';
|
||||||
import { FiltersSettingsEditor } from './FiltersSettingsEditor';
|
import { FiltersSettingsEditor } from './FiltersSettingsEditor';
|
||||||
import { useDescription } from './useDescription';
|
import { useDescription } from './useDescription';
|
||||||
import { useQuery } from '../../ElasticsearchQueryContext';
|
import { useQuery } from '../../ElasticsearchQueryContext';
|
||||||
import { describeMetric } from '../../../../utils';
|
|
||||||
|
|
||||||
const inlineFieldProps: Partial<ComponentProps<typeof InlineField>> = {
|
const inlineFieldProps: Partial<ComponentProps<typeof InlineField>> = {
|
||||||
labelWidth: 16,
|
labelWidth: 16,
|
||||||
|
|
@ -22,8 +27,7 @@ export const SettingsEditor: FunctionComponent<Props> = ({ bucketAgg }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { metrics } = useQuery();
|
const { metrics } = useQuery();
|
||||||
const settingsDescription = useDescription(bucketAgg);
|
const settingsDescription = useDescription(bucketAgg);
|
||||||
|
const orderBy = createOrderByOptionsFromMetrics(metrics);
|
||||||
const orderBy = [...orderByOptions, ...(metrics || []).map(m => ({ label: describeMetric(m), value: m.id }))];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsEditorContainer label={settingsDescription}>
|
<SettingsEditorContainer label={settingsDescription}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describeMetric } from '../../../../utils';
|
import { describeMetric, convertOrderByToMetricId } from '../../../../utils';
|
||||||
import { useQuery } from '../../ElasticsearchQueryContext';
|
import { useQuery } from '../../ElasticsearchQueryContext';
|
||||||
import { BucketAggregation } from '../aggregations';
|
import { BucketAggregation } from '../aggregations';
|
||||||
import { bucketAggregationConfig, orderByOptions, orderOptions } from '../utils';
|
import { bucketAggregationConfig, orderByOptions, orderOptions } from '../utils';
|
||||||
|
|
@ -34,7 +34,7 @@ export const useDescription = (bucketAgg: BucketAggregation): string => {
|
||||||
if (orderByOption) {
|
if (orderByOption) {
|
||||||
description += orderByOption.label;
|
description += orderByOption.label;
|
||||||
} else {
|
} else {
|
||||||
const metric = metrics?.find(m => m.id === orderBy);
|
const metric = metrics?.find(m => m.id === convertOrderByToMetricId(orderBy));
|
||||||
if (metric) {
|
if (metric) {
|
||||||
description += describeMetric(metric);
|
description += describeMetric(metric);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { BucketsConfiguration } from '../../../types';
|
import { BucketsConfiguration } from '../../../types';
|
||||||
import { defaultFilter } from './SettingsEditor/FiltersSettingsEditor/utils';
|
import { defaultFilter } from './SettingsEditor/FiltersSettingsEditor/utils';
|
||||||
|
import { describeMetric } from '../../../utils';
|
||||||
|
import {
|
||||||
|
ExtendedStatMetaType,
|
||||||
|
ExtendedStats,
|
||||||
|
MetricAggregation,
|
||||||
|
Percentiles,
|
||||||
|
} from '../MetricAggregationsEditor/aggregations';
|
||||||
|
import { SelectableValue } from '@grafana/data';
|
||||||
|
|
||||||
export const bucketAggregationConfig: BucketsConfiguration = {
|
export const bucketAggregationConfig: BucketsConfiguration = {
|
||||||
terms: {
|
terms: {
|
||||||
|
|
@ -46,7 +54,8 @@ export const bucketAggregationConfig: BucketsConfiguration = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: Define better types for the following
|
// TODO: Define better types for the following
|
||||||
export const orderOptions = [
|
type OrderByOption = SelectableValue<string>;
|
||||||
|
export const orderOptions: OrderByOption[] = [
|
||||||
{ label: 'Top', value: 'desc' },
|
{ label: 'Top', value: 'desc' },
|
||||||
{ label: 'Bottom', value: 'asc' },
|
{ label: 'Bottom', value: 'asc' },
|
||||||
];
|
];
|
||||||
|
|
@ -77,3 +86,58 @@ export const intervalOptions = [
|
||||||
{ label: '1h', value: '1h' },
|
{ label: '1h', value: '1h' },
|
||||||
{ label: '1d', value: '1d' },
|
{ label: '1d', value: '1d' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This returns the valid options for each of the enabled extended stat
|
||||||
|
*/
|
||||||
|
function createOrderByOptionsForExtendedStats(metric: ExtendedStats): OrderByOption[] {
|
||||||
|
if (!metric.meta) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const metaKeys = Object.keys(metric.meta) as ExtendedStatMetaType[];
|
||||||
|
return metaKeys
|
||||||
|
.filter(key => metric.meta?.[key])
|
||||||
|
.map(key => {
|
||||||
|
let method = key as string;
|
||||||
|
// The bucket path for std_deviation_bounds.lower and std_deviation_bounds.upper
|
||||||
|
// is accessed via std_lower and std_upper, respectively.
|
||||||
|
if (key === 'std_deviation_bounds_lower') {
|
||||||
|
method = 'std_lower';
|
||||||
|
}
|
||||||
|
if (key === 'std_deviation_bounds_upper') {
|
||||||
|
method = 'std_upper';
|
||||||
|
}
|
||||||
|
return { label: `${describeMetric(metric)} (${method})`, value: `${metric.id}[${method}]` };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This returns the valid options for each of the percents listed in the percentile settings
|
||||||
|
*/
|
||||||
|
function createOrderByOptionsForPercentiles(metric: Percentiles): OrderByOption[] {
|
||||||
|
if (!metric.settings?.percents) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return metric.settings.percents.map(percent => {
|
||||||
|
// The bucket path for percentile numbers is appended with a `.0` if the number is whole
|
||||||
|
// otherwise you have to use the actual value.
|
||||||
|
const percentString = /^\d+\.\d+/.test(`${percent}`) ? percent : `${percent}.0`;
|
||||||
|
return { label: `${describeMetric(metric)} (${percent})`, value: `${metric.id}[${percentString}]` };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This creates all the valid order by options based on the metrics
|
||||||
|
*/
|
||||||
|
export const createOrderByOptionsFromMetrics = (metrics: MetricAggregation[] = []): OrderByOption[] => {
|
||||||
|
const metricOptions = metrics.flatMap(metric => {
|
||||||
|
if (metric.type === 'extended_stats') {
|
||||||
|
return createOrderByOptionsForExtendedStats(metric);
|
||||||
|
} else if (metric.type === 'percentiles') {
|
||||||
|
return createOrderByOptionsForPercentiles(metric);
|
||||||
|
} else {
|
||||||
|
return { label: describeMetric(metric), value: metric.id };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [...orderByOptions, ...metricOptions];
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ export interface ExtendedStats extends MetricAggregationWithField, MetricAggrega
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Percentiles extends MetricAggregationWithField, MetricAggregationWithInlineScript {
|
export interface Percentiles extends MetricAggregationWithField, MetricAggregationWithInlineScript {
|
||||||
type: 'percentiles';
|
type: 'percentiles';
|
||||||
settings?: {
|
settings?: {
|
||||||
percents?: string[];
|
percents?: string[];
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from './components/QueryEditor/MetricAggregationsEditor/aggregations';
|
} from './components/QueryEditor/MetricAggregationsEditor/aggregations';
|
||||||
import { defaultBucketAgg, defaultMetricAgg, findMetricById } from './query_def';
|
import { defaultBucketAgg, defaultMetricAgg, findMetricById } from './query_def';
|
||||||
import { ElasticsearchQuery } from './types';
|
import { ElasticsearchQuery } from './types';
|
||||||
|
import { convertOrderByToMetricId } from './utils';
|
||||||
|
|
||||||
export class ElasticQueryBuilder {
|
export class ElasticQueryBuilder {
|
||||||
timeField: string;
|
timeField: string;
|
||||||
|
|
@ -34,7 +35,6 @@ export class ElasticQueryBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTermsAgg(aggDef: Terms, queryNode: { terms?: any; aggs?: any }, target: ElasticsearchQuery) {
|
buildTermsAgg(aggDef: Terms, queryNode: { terms?: any; aggs?: any }, target: ElasticsearchQuery) {
|
||||||
let metricRef;
|
|
||||||
queryNode.terms = { field: aggDef.field };
|
queryNode.terms = { field: aggDef.field };
|
||||||
|
|
||||||
if (!aggDef.settings) {
|
if (!aggDef.settings) {
|
||||||
|
|
@ -54,14 +54,17 @@ export class ElasticQueryBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
// if metric ref, look it up and add it to this agg level
|
// if metric ref, look it up and add it to this agg level
|
||||||
metricRef = parseInt(aggDef.settings.orderBy, 10);
|
const metricId = convertOrderByToMetricId(aggDef.settings.orderBy);
|
||||||
if (!isNaN(metricRef)) {
|
if (metricId) {
|
||||||
for (let metric of target.metrics || []) {
|
for (let metric of target.metrics || []) {
|
||||||
if (metric.id === aggDef.settings.orderBy) {
|
if (metric.id === metricId) {
|
||||||
queryNode.aggs = {};
|
if (metric.type === 'count') {
|
||||||
queryNode.aggs[metric.id] = {};
|
queryNode.terms.order = { _count: aggDef.settings.order };
|
||||||
if (isMetricAggregationWithField(metric)) {
|
} else if (isMetricAggregationWithField(metric)) {
|
||||||
queryNode.aggs[metric.id][metric.type] = { field: metric.field };
|
queryNode.aggs = {};
|
||||||
|
queryNode.aggs[metric.id] = {
|
||||||
|
[metric.type]: { field: metric.field },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,84 @@ describe('ElasticQueryBuilder', () => {
|
||||||
expect(secondLevel.aggs['5'].avg.field).toBe('@value');
|
expect(secondLevel.aggs['5'].avg.field).toBe('@value');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('with term agg and order by count agg', () => {
|
||||||
|
const query = builder.build(
|
||||||
|
{
|
||||||
|
refId: 'A',
|
||||||
|
metrics: [
|
||||||
|
{ type: 'count', id: '1' },
|
||||||
|
{ type: 'avg', field: '@value', id: '5' },
|
||||||
|
],
|
||||||
|
bucketAggs: [
|
||||||
|
{
|
||||||
|
type: 'terms',
|
||||||
|
field: '@host',
|
||||||
|
settings: { size: '5', order: 'asc', orderBy: '1' },
|
||||||
|
id: '2',
|
||||||
|
},
|
||||||
|
{ type: 'date_histogram', field: '@timestamp', id: '3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
100,
|
||||||
|
'1000'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(query.aggs['2'].terms.order._count).toEqual('asc');
|
||||||
|
expect(query.aggs['2'].aggs).not.toHaveProperty('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with term agg and order by extended_stats agg', () => {
|
||||||
|
const query = builder.build(
|
||||||
|
{
|
||||||
|
refId: 'A',
|
||||||
|
metrics: [{ type: 'extended_stats', id: '1', field: '@value', meta: { std_deviation: true } }],
|
||||||
|
bucketAggs: [
|
||||||
|
{
|
||||||
|
type: 'terms',
|
||||||
|
field: '@host',
|
||||||
|
settings: { size: '5', order: 'asc', orderBy: '1[std_deviation]' },
|
||||||
|
id: '2',
|
||||||
|
},
|
||||||
|
{ type: 'date_histogram', field: '@timestamp', id: '3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
100,
|
||||||
|
'1000'
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstLevel = query.aggs['2'];
|
||||||
|
const secondLevel = firstLevel.aggs['3'];
|
||||||
|
|
||||||
|
expect(firstLevel.aggs['1'].extended_stats.field).toBe('@value');
|
||||||
|
expect(secondLevel.aggs['1'].extended_stats.field).toBe('@value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with term agg and order by percentiles agg', () => {
|
||||||
|
const query = builder.build(
|
||||||
|
{
|
||||||
|
refId: 'A',
|
||||||
|
metrics: [{ type: 'percentiles', id: '1', field: '@value', settings: { percents: ['95', '99'] } }],
|
||||||
|
bucketAggs: [
|
||||||
|
{
|
||||||
|
type: 'terms',
|
||||||
|
field: '@host',
|
||||||
|
settings: { size: '5', order: 'asc', orderBy: '1[95.0]' },
|
||||||
|
id: '2',
|
||||||
|
},
|
||||||
|
{ type: 'date_histogram', field: '@timestamp', id: '3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
100,
|
||||||
|
'1000'
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstLevel = query.aggs['2'];
|
||||||
|
const secondLevel = firstLevel.aggs['3'];
|
||||||
|
|
||||||
|
expect(firstLevel.aggs['1'].percentiles.field).toBe('@value');
|
||||||
|
expect(secondLevel.aggs['1'].percentiles.field).toBe('@value');
|
||||||
|
});
|
||||||
|
|
||||||
it('with term agg and valid min_doc_count', () => {
|
it('with term agg and valid min_doc_count', () => {
|
||||||
const query = builder.build(
|
const query = builder.build(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -52,3 +52,13 @@ export const removeEmpty = <T>(obj: T): Partial<T> =>
|
||||||
[key]: value,
|
[key]: value,
|
||||||
};
|
};
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function converts an order by string to the correct metric id For example,
|
||||||
|
* if the user uses the standard deviation extended stat for the order by,
|
||||||
|
* the value would be "1[std_deviation]" and this would return "1"
|
||||||
|
*/
|
||||||
|
export const convertOrderByToMetricId = (orderBy: string): string | undefined => {
|
||||||
|
const metricIdMatches = orderBy.match(/^(\d+)/);
|
||||||
|
return metricIdMatches ? metricIdMatches[1] : void 0;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue