[SLO] Add preview chart to custom metric indicator (#161597)

## Summary

This PR adds a preview chart to the custom metric indicator.

<img width="691" alt="image"
src="9c2c5fa0-d6b0-4d93-86cd-eee38052db16">
This commit is contained in:
Chris Cowan 2023-07-11 13:53:11 -06:00 committed by GitHub
parent cb853a1d9a
commit 7035adb4cc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 229 additions and 54 deletions

View file

@ -22,6 +22,7 @@ import {
useFetchIndexPatternFields,
} from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { CreateSLOForm } from '../../types';
import { DataPreviewChart } from '../common/data_preview_chart';
import { QueryBuilder } from '../common/query_builder';
import { IndexSelection } from '../custom_common/index_selection';
import { MetricIndicator } from './metric_indicator';
@ -221,6 +222,7 @@ export function CustomMetricIndicatorTypeForm() {
}
/>
</EuiPanel>
<DataPreviewChart />
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GetHistogramIndicatorAggregation should generate a aggregation for good events 1`] = `
Object {
"_good_A": Object {
"aggs": Object {
"sum": Object {
"sum": Object {
"field": "total",
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"_good_B": Object {
"aggs": Object {
"sum": Object {
"sum": Object {
"field": "processed",
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"goodEvents": Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_good_A>sum",
"B": "_good_B>sum",
},
"script": Object {
"lang": "painless",
"source": "params.A - params.B",
},
},
},
}
`;
exports[`GetHistogramIndicatorAggregation should generate a aggregation for total events 1`] = `
Object {
"_total_A": Object {
"aggs": Object {
"sum": Object {
"sum": Object {
"field": "total",
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"totalEvents": Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_total_A>sum",
},
"script": Object {
"lang": "painless",
"source": "params.A",
},
},
},
}
`;

View file

@ -0,0 +1,29 @@
/*
* 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 { GetCustomMetricIndicatorAggregation } from './get_custom_metric_indicator_aggregation';
import { createMetricCustomIndicator } from '../fixtures/slo';
describe('GetHistogramIndicatorAggregation', () => {
it('should generate a aggregation for good events', () => {
const getCustomMetricIndicatorAggregation = new GetCustomMetricIndicatorAggregation(
createMetricCustomIndicator()
);
expect(
getCustomMetricIndicatorAggregation.execute({ type: 'good', aggregationKey: 'goodEvents' })
).toMatchSnapshot();
});
it('should generate a aggregation for total events', () => {
const getCustomMetricIndicatorAggregation = new GetCustomMetricIndicatorAggregation(
createMetricCustomIndicator()
);
expect(
getCustomMetricIndicatorAggregation.execute({ type: 'total', aggregationKey: 'totalEvents' })
).toMatchSnapshot();
});
});

View file

@ -0,0 +1,67 @@
/*
* 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 { MetricCustomIndicator } from '@kbn/slo-schema';
import { getElastichsearchQueryOrThrow } from '../transform_generators';
type MetricCustomMetricDef =
| MetricCustomIndicator['params']['good']
| MetricCustomIndicator['params']['total'];
export class GetCustomMetricIndicatorAggregation {
constructor(private indicator: MetricCustomIndicator) {}
private buildMetricAggregations(type: 'good' | 'total', metricDef: MetricCustomMetricDef) {
return metricDef.metrics.reduce((acc, metric) => {
const filter = metric.filter
? getElastichsearchQueryOrThrow(metric.filter)
: { match_all: {} };
return {
...acc,
[`_${type}_${metric.name}`]: {
filter,
aggs: {
sum: {
[metric.aggregation]: { field: metric.field },
},
},
},
};
}, {});
}
private convertEquationToPainless(bucketsPath: Record<string, string>, equation: string) {
const workingEquation = equation || Object.keys(bucketsPath).join(' + ');
return Object.keys(bucketsPath).reduce((acc, key) => {
return acc.replace(key, `params.${key}`);
}, workingEquation);
}
private buildMetricEquation(type: 'good' | 'total', metricDef: MetricCustomMetricDef) {
const bucketsPath = metricDef.metrics.reduce(
(acc, metric) => ({ ...acc, [metric.name]: `_${type}_${metric.name}>sum` }),
{}
);
return {
bucket_script: {
buckets_path: bucketsPath,
script: {
source: this.convertEquationToPainless(bucketsPath, metricDef.equation),
lang: 'painless',
},
},
};
}
public execute({ type, aggregationKey }: { type: 'good' | 'total'; aggregationKey: string }) {
const indicatorDef = this.indicator.params[type];
return {
...this.buildMetricAggregations(type, indicatorDef),
[aggregationKey]: this.buildMetricEquation(type, indicatorDef),
};
}
}

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './get_custom_metric_indicator_aggregation';

View file

@ -71,7 +71,7 @@ export const createKQLCustomIndicator = (
export const createMetricCustomIndicator = (
params: Partial<MetricCustomIndicator['params']> = {}
): Indicator => ({
): MetricCustomIndicator => ({
type: 'sli.metric.custom',
params: {
index: 'my-index*',

View file

@ -10,6 +10,7 @@ import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
import { ALL_VALUE, GetPreviewDataParams, GetPreviewDataResponse } from '@kbn/slo-schema';
import { computeSLI } from '../../domain/services';
import { InvalidQueryError } from '../../errors';
import { GetCustomMetricIndicatorAggregation } from './aggregations';
export class GetPreviewData {
constructor(private esClient: ElasticsearchClient) {}
@ -208,6 +209,47 @@ export class GetPreviewData {
} catch (err) {
throw new InvalidQueryError(`Invalid ES query`);
}
case 'sli.metric.custom':
const timestampField = params.indicator.params.timestampField;
const filterQuery = getElastichsearchQueryOrThrow(params.indicator.params.filter);
const getCustomMetricIndicatorAggregation = new GetCustomMetricIndicatorAggregation(
params.indicator
);
const result = await this.esClient.search({
index: params.indicator.params.index,
query: {
bool: {
filter: [{ range: { [timestampField]: { gte: 'now-60m' } } }, filterQuery],
},
},
aggs: {
perMinute: {
date_histogram: {
field: timestampField,
fixed_interval: '1m',
},
aggs: {
...getCustomMetricIndicatorAggregation.execute({
type: 'good',
aggregationKey: 'good',
}),
...getCustomMetricIndicatorAggregation.execute({
type: 'total',
aggregationKey: 'total',
}),
},
},
},
});
// @ts-ignore buckets is not improperly typed
return result.aggregations?.perMinute.buckets.map((bucket) => ({
date: bucket.key_as_string,
sliValue:
!!bucket.good && !!bucket.total
? computeSLI(bucket.good.value, bucket.total.value)
: null,
}));
default:
return [];

View file

@ -17,10 +17,7 @@ import {
getSLOTransformId,
} from '../../../assets/constants';
import { MetricCustomIndicator, SLO } from '../../../domain/models';
type MetricCustomMetricDef =
| MetricCustomIndicator['params']['good']
| MetricCustomIndicator['params']['total'];
import { GetCustomMetricIndicatorAggregation } from '../aggregations';
export const INVALID_EQUATION_REGEX = /[^A-Z|+|\-|\s|\d+|\.|\(|\)|\/|\*|>|<|=|\?|\:|&|\!|\|]+/g;
@ -61,48 +58,6 @@ export class MetricCustomTransformGenerator extends TransformGenerator {
};
}
private buildMetricAggregations(type: 'good' | 'total', metricDef: MetricCustomMetricDef) {
return metricDef.metrics.reduce((acc, metric) => {
const filter = metric.filter
? getElastichsearchQueryOrThrow(metric.filter)
: { match_all: {} };
return {
...acc,
[`_${type}_${metric.name}`]: {
filter,
aggs: {
sum: {
[metric.aggregation]: { field: metric.field },
},
},
},
};
}, {});
}
private convertEquationToPainless(bucketsPath: Record<string, string>, equation: string) {
const workingEquation = equation || Object.keys(bucketsPath).join(' + ');
return Object.keys(bucketsPath).reduce((acc, key) => {
return acc.replace(key, `params.${key}`);
}, workingEquation);
}
private buildMetricEquation(type: 'good' | 'total', metricDef: MetricCustomMetricDef) {
const bucketsPath = metricDef.metrics.reduce(
(acc, metric) => ({ ...acc, [metric.name]: `_${type}_${metric.name}>sum` }),
{}
);
return {
bucket_script: {
buckets_path: bucketsPath,
script: {
source: this.convertEquationToPainless(bucketsPath, metricDef.equation),
lang: 'painless',
},
},
};
}
private buildAggregations(slo: SLO, indicator: MetricCustomIndicator) {
if (indicator.params.good.equation.match(INVALID_EQUATION_REGEX)) {
throw new Error(`Invalid equation: ${indicator.params.good.equation}`);
@ -112,14 +67,16 @@ export class MetricCustomTransformGenerator extends TransformGenerator {
throw new Error(`Invalid equation: ${indicator.params.total.equation}`);
}
const goodAggregations = this.buildMetricAggregations('good', indicator.params.good);
const totalAggregations = this.buildMetricAggregations('total', indicator.params.total);
const getCustomMetricIndicatorAggregation = new GetCustomMetricIndicatorAggregation(indicator);
return {
...goodAggregations,
...totalAggregations,
'slo.numerator': this.buildMetricEquation('good', indicator.params.good),
'slo.denominator': this.buildMetricEquation('total', indicator.params.total),
...getCustomMetricIndicatorAggregation.execute({
type: 'good',
aggregationKey: 'slo.numerator',
}),
...getCustomMetricIndicatorAggregation.execute({
type: 'total',
aggregationKey: 'slo.denominator',
}),
...(timeslicesBudgetingMethodSchema.is(slo.budgetingMethod) && {
'slo.isGoodSlice': {
bucket_script: {