[SLO] Add indicator to support histogram fields (#161582)

## Summary


This PR add a new indicator to support histogram fields. This will allow
you to either use a `range` aggregation or `value_count` aggregation for
the good and total events; including support for filtering with KQL on
both event types. When using a `range` aggregation, both the `from` and
`to` thresholds are required for the range and events will be to total
number of events within that range.[ Keep in mind, with the `range`
aggregation, the range includes the `from` value and excludes the `to`
value.](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-range-aggregation.html)

This PR also includes support for using the histogram field for a
"Custom Metric" indicator, `sum` is calculated on the values and not the
counts. If you need it calculated on the counts then you have to use the
histogram indicator.

<img width="776" alt="image"
src="1d46b722-df13-417e-bf3b-b3c450933da2">

---------

Co-authored-by: Kevin Delemme <kdelemme@gmail.com>
This commit is contained in:
Chris Cowan 2023-07-11 17:44:08 -06:00 committed by GitHub
parent a16f9482e3
commit 06f7cbf9b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1924 additions and 286 deletions

View file

@ -15,6 +15,7 @@ import {
indicatorTypesSchema,
kqlCustomIndicatorSchema,
metricCustomIndicatorSchema,
histogramIndicatorSchema,
objectiveSchema,
optionalSettingsSchema,
previewDataSchema,
@ -200,6 +201,7 @@ type Indicator = t.OutputOf<typeof indicatorSchema>;
type APMTransactionErrorRateIndicator = t.OutputOf<typeof apmTransactionErrorRateIndicatorSchema>;
type APMTransactionDurationIndicator = t.OutputOf<typeof apmTransactionDurationIndicatorSchema>;
type MetricCustomIndicator = t.OutputOf<typeof metricCustomIndicatorSchema>;
type HistogramIndicator = t.OutputOf<typeof histogramIndicatorSchema>;
type KQLCustomIndicator = t.OutputOf<typeof kqlCustomIndicatorSchema>;
export {
@ -247,6 +249,7 @@ export type {
IndicatorType,
Indicator,
MetricCustomIndicator,
HistogramIndicator,
KQLCustomIndicator,
TimeWindow,
};

View file

@ -85,6 +85,47 @@ const metricCustomIndicatorSchema = t.type({
}),
});
const rangeHistogramMetricType = t.literal('range');
const rangeBasedHistogramMetricDef = t.intersection([
t.type({
field: t.string,
aggregation: rangeHistogramMetricType,
from: t.number,
to: t.number,
}),
t.partial({
filter: t.string,
}),
]);
const valueCountHistogramMetricType = t.literal('value_count');
const valueCountBasedHistogramMetricDef = t.intersection([
t.type({
field: t.string,
aggregation: valueCountHistogramMetricType,
}),
t.partial({
filter: t.string,
}),
]);
const histogramMetricDef = t.union([
valueCountBasedHistogramMetricDef,
rangeBasedHistogramMetricDef,
]);
const histogramIndicatorTypeSchema = t.literal('sli.histogram.custom');
const histogramIndicatorSchema = t.type({
type: histogramIndicatorTypeSchema,
params: t.type({
index: t.string,
timestampField: t.string,
filter: t.string,
good: histogramMetricDef,
total: histogramMetricDef,
}),
});
const indicatorDataSchema = t.type({
dateRange: dateRangeSchema,
good: t.number,
@ -96,6 +137,7 @@ const indicatorTypesSchema = t.union([
apmTransactionErrorRateIndicatorTypeSchema,
kqlCustomIndicatorTypeSchema,
metricCustomIndicatorTypeSchema,
histogramIndicatorTypeSchema,
]);
// Validate that a string is a comma separated list of indicator types,
@ -122,6 +164,7 @@ const indicatorSchema = t.union([
apmTransactionErrorRateIndicatorSchema,
kqlCustomIndicatorSchema,
metricCustomIndicatorSchema,
histogramIndicatorSchema,
]);
export {
@ -133,6 +176,8 @@ export {
kqlCustomIndicatorTypeSchema,
metricCustomIndicatorTypeSchema,
metricCustomIndicatorSchema,
histogramIndicatorTypeSchema,
histogramIndicatorSchema,
indicatorSchema,
indicatorTypesArraySchema,
indicatorTypesSchema,

View file

@ -8,14 +8,17 @@ info:
license:
name: Elastic License 2.0
url: https://www.elastic.co/licensing/elastic-license
servers:
- url: http://localhost:5601
description: local
security:
- basicAuth: []
- apiKeyAuth: []
tags:
- name: slo
description: SLO APIs enable you to define, manage and track service-level objectives
- name: composite slo
description: Composite SLO APIs enable you to define, manage and track a group of SLOs.
servers:
- url: http://localhost:5601
description: local
paths:
/s/{spaceId}/api/observability/slos:
post:
@ -697,6 +700,103 @@ components:
description: The type of indicator.
type: string
example: sli.metric.custom
indicator_properties_histogram:
title: Histogram indicator
required:
- type
- params
description: Defines properties for a histogram indicator type
type: object
properties:
params:
description: An object containing the indicator parameters.
type: object
nullable: false
required:
- index
- timestampField
- good
- total
properties:
index:
description: The index or index pattern to use
type: string
example: my-service-*
filter:
description: the KQL query to filter the documents with.
type: string
example: 'field.environment : "production" and service.name : "my-service"'
timestampField:
description: |
The timestamp field used in the source indice.
type: string
example: timestamp
good:
description: |
An object defining the "good" events
type: object
required:
- aggregation
- field
properties:
field:
description: The field use to aggregate the good events.
type: string
example: processor.latency
aggregation:
description: The type of aggregation to use.
type: string
example: value_count
enum:
- value_count
- range
filter:
description: The filter for good events.
type: string
example: 'processor.outcome: "success"'
from:
description: The starting value of the range. Only required for "range" aggregations.
type: number
example: 0
to:
description: The ending value of the range. Only required for "range" aggregations.
type: number
example: 100
total:
description: |
An object defining the "total" events
type: object
required:
- aggregation
- field
properties:
field:
description: The field use to aggregate the good events.
type: string
example: processor.latency
aggregation:
description: The type of aggregation to use.
type: string
example: value_count
enum:
- value_count
- range
filter:
description: The filter for total events.
type: string
example: 'processor.outcome : *'
from:
description: The starting value of the range. Only required for "range" aggregations.
type: number
example: 0
to:
description: The ending value of the range. Only required for "range" aggregations.
type: number
example: 100
type:
description: The type of indicator.
type: string
example: sli.histogram.custom
time_window:
title: Time window
required:
@ -816,6 +916,7 @@ components:
- $ref: '#/components/schemas/indicator_properties_apm_availability'
- $ref: '#/components/schemas/indicator_properties_apm_latency'
- $ref: '#/components/schemas/indicator_properties_custom_metric'
- $ref: '#/components/schemas/indicator_properties_histogram'
timeWindow:
$ref: '#/components/schemas/time_window'
budgetingMethod:
@ -958,6 +1059,7 @@ components:
- $ref: '#/components/schemas/indicator_properties_apm_availability'
- $ref: '#/components/schemas/indicator_properties_apm_latency'
- $ref: '#/components/schemas/indicator_properties_custom_metric'
- $ref: '#/components/schemas/indicator_properties_histogram'
timeWindow:
$ref: '#/components/schemas/time_window'
budgetingMethod:
@ -1010,6 +1112,7 @@ components:
- $ref: '#/components/schemas/indicator_properties_apm_availability'
- $ref: '#/components/schemas/indicator_properties_apm_latency'
- $ref: '#/components/schemas/indicator_properties_custom_metric'
- $ref: '#/components/schemas/indicator_properties_histogram'
timeWindow:
$ref: '#/components/schemas/time_window'
budgetingMethod:
@ -1054,6 +1157,3 @@ components:
example: 0.9836
errorBudget:
$ref: '#/components/schemas/error_budget'
security:
- basicAuth: []
- apiKeyAuth: []

View file

@ -26,6 +26,7 @@ properties:
- $ref: "indicator_properties_apm_availability.yaml"
- $ref: "indicator_properties_apm_latency.yaml"
- $ref: "indicator_properties_custom_metric.yaml"
- $ref: 'indicator_properties_histogram.yaml'
timeWindow:
$ref: "time_window.yaml"
budgetingMethod:

View file

@ -0,0 +1,92 @@
title: Histogram indicator
required:
- type
- params
description: Defines properties for a histogram indicator type
type: object
properties:
params:
description: An object containing the indicator parameters.
type: object
nullable: false
required:
- index
- timestampField
- good
- total
properties:
index:
description: The index or index pattern to use
type: string
example: my-service-*
filter:
description: the KQL query to filter the documents with.
type: string
example: 'field.environment : "production" and service.name : "my-service"'
timestampField:
description: >
The timestamp field used in the source indice.
type: string
example: timestamp
good:
description: >
An object defining the "good" events
type: object
required:
- aggregation
- field
properties:
field:
description: The field use to aggregate the good events.
type: string
example: processor.latency
aggregation:
description: The type of aggregation to use.
type: string
example: value_count
enum: [value_count, range]
filter:
description: The filter for good events.
type: string
example: "processor.outcome: \"success\""
from:
description: The starting value of the range. Only required for "range" aggregations.
type: number
example: 0
to:
description: The ending value of the range. Only required for "range" aggregations.
type: number
example: 100
total:
description: >
An object defining the "total" events
type: object
required:
- aggregation
- field
properties:
field:
description: The field use to aggregate the good events.
type: string
example: processor.latency
aggregation:
description: The type of aggregation to use.
type: string
example: value_count
enum: [value_count, range]
filter:
description: The filter for total events.
type: string
example: "processor.outcome : *"
from:
description: The starting value of the range. Only required for "range" aggregations.
type: number
example: 0
to:
description: The ending value of the range. Only required for "range" aggregations.
type: number
example: 100
type:
description: The type of indicator.
type: string
example: sli.histogram.custom

View file

@ -19,6 +19,7 @@ properties:
- $ref: "indicator_properties_apm_availability.yaml"
- $ref: "indicator_properties_apm_latency.yaml"
- $ref: "indicator_properties_custom_metric.yaml"
- $ref: "indicator_properties_histogram.yaml"
timeWindow:
$ref: "time_window.yaml"
budgetingMethod:

View file

@ -16,6 +16,7 @@ properties:
- $ref: "indicator_properties_apm_availability.yaml"
- $ref: "indicator_properties_apm_latency.yaml"
- $ref: "indicator_properties_custom_metric.yaml"
- $ref: "indicator_properties_histogram.yaml"
timeWindow:
$ref: "time_window.yaml"
budgetingMethod:

View file

@ -16,20 +16,13 @@ import {
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import {
Field,
useFetchIndexPatternFields,
} from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { useFetchIndexPatternFields } from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { createOptionsFromFields } from '../../helpers/create_options';
import { CreateSLOForm } from '../../types';
import { DataPreviewChart } from '../common/data_preview_chart';
import { QueryBuilder } from '../common/query_builder';
import { IndexSelection } from '../custom_common/index_selection';
interface Option {
label: string;
value: string;
}
export function CustomKqlIndicatorTypeForm() {
const { control, watch, getFieldState } = useFormContext<CreateSLOForm>();
@ -80,7 +73,7 @@ export function CustomKqlIndicatorTypeForm() {
field.onChange('');
}}
options={createOptions(timestampFields)}
options={createOptionsFromFields(timestampFields)}
selectedOptions={
!!index &&
!!field.value &&
@ -186,9 +179,3 @@ export function CustomKqlIndicatorTypeForm() {
</EuiFlexGroup>
);
}
function createOptions(fields: Field[]): Option[] {
return fields
.map((field) => ({ label: field.name, value: field.name }))
.sort((a, b) => String(a.label).localeCompare(b.label));
}

View file

@ -17,10 +17,8 @@ import {
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import {
Field,
useFetchIndexPatternFields,
} from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { useFetchIndexPatternFields } from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { createOptionsFromFields } from '../../helpers/create_options';
import { CreateSLOForm } from '../../types';
import { DataPreviewChart } from '../common/data_preview_chart';
import { QueryBuilder } from '../common/query_builder';
@ -29,11 +27,6 @@ import { MetricIndicator } from './metric_indicator';
export { NEW_CUSTOM_METRIC } from './metric_indicator';
interface Option {
label: string;
value: string;
}
export function CustomMetricIndicatorTypeForm() {
const { control, watch, getFieldState } = useFormContext<CreateSLOForm>();
@ -85,7 +78,7 @@ export function CustomMetricIndicatorTypeForm() {
field.onChange('');
}}
options={createOptions(timestampFields)}
options={createOptionsFromFields(timestampFields)}
selectedOptions={
!!watch('indicator.params.index') &&
!!field.value &&
@ -226,9 +219,3 @@ export function CustomMetricIndicatorTypeForm() {
</EuiFlexGroup>
);
}
function createOptions(fields: Field[]): Option[] {
return fields
.map((field) => ({ label: field.name, value: field.name }))
.sort((a, b) => String(a.label).localeCompare(b.label));
}

View file

@ -22,14 +22,10 @@ import { first, range, xor } from 'lodash';
import React, { ReactNode } from 'react';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { Field } from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { createOptionsFromFields } from '../../helpers/create_options';
import { CreateSLOForm } from '../../types';
import { QueryBuilder } from '../common/query_builder';
interface Option {
label: string;
value: string;
}
interface MetricIndicatorProps {
type: 'good' | 'total';
indexFields: Field[] | undefined;
@ -52,16 +48,12 @@ const validateEquation = (value: string) => {
return result === null;
};
function createOptions(fields: Field[]): Option[] {
return fields
.map((field) => ({ label: field.name, value: field.name }))
.sort((a, b) => String(a.label).localeCompare(b.label));
}
function createEquationFromMetric(names: string[]) {
return names.join(' + ');
}
const SUPPORTED_FIELD_TYPES = ['number', 'histogram'];
export function MetricIndicator({
type,
indexFields,
@ -73,7 +65,9 @@ export function MetricIndicator({
equationTooltip,
}: MetricIndicatorProps) {
const { control, watch, setValue, register } = useFormContext<CreateSLOForm>();
const metricFields = (indexFields ?? []).filter((field) => field.type === 'number');
const metricFields = (indexFields ?? []).filter((field) =>
SUPPORTED_FIELD_TYPES.includes(field.type)
);
const { fields, append, remove } = useFieldArray({
control,
@ -166,7 +160,7 @@ export function MetricIndicator({
]
: []
}
options={createOptions(metricFields)}
options={createOptionsFromFields(metricFields)}
/>
)}
/>

View file

@ -0,0 +1,296 @@
/*
* 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 React, { Fragment } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { i18n } from '@kbn/i18n';
import {
EuiComboBox,
EuiComboBoxOptionOption,
EuiFieldNumber,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIconTip,
EuiSpacer,
} from '@elastic/eui';
import { CreateSLOForm } from '../../types';
import { QueryBuilder } from '../common/query_builder';
import { Field } from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { createOptionsFromFields } from '../../helpers/create_options';
interface HistogramIndicatorProps {
type: 'good' | 'total';
indexFields: Field[] | undefined;
isLoadingIndex: boolean;
}
const AGGREGATIONS = {
value_count: {
value: 'value_count',
label: i18n.translate('xpack.observability.slo.sloEdit.sliType.histogram.valueCountLabel', {
defaultMessage: 'Value count',
}),
},
range: {
value: 'range',
label: i18n.translate('xpack.observability.slo.sloEdit.sliType.histogram.rangeLabel', {
defaultMessage: 'Range',
}),
},
};
const AGGREGATION_OPTIONS = Object.values(AGGREGATIONS);
export function HistogramIndicator({ type, indexFields, isLoadingIndex }: HistogramIndicatorProps) {
const { control, watch } = useFormContext<CreateSLOForm>();
const histogramFields = (indexFields ?? []).filter((field) => field.type === 'histogram');
const indexPattern = watch('indicator.params.index');
const aggregation = watch(`indicator.params.${type}.aggregation`);
const aggregationTooltip = (
<EuiIconTip
content={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.histogram.aggregationTooltip',
{
defaultMessage:
'The "value count" aggreation will return the total count for the histogram field. Range will return the count from the histogram field that is within the range defined below.',
}
)}
position="top"
/>
);
const fromTooltip = (
<EuiIconTip
content={i18n.translate('xpack.observability.slo.sloEdit.sliType.histogram.fromTooltip', {
defaultMessage: 'The "from" value is inclusive.',
})}
position="top"
/>
);
const toTooltip = (
<EuiIconTip
content={i18n.translate('xpack.observability.slo.sloEdit.sliType.histogram.toTooltip', {
defaultMessage: 'The "to" value is NOT inclusive.',
})}
position="top"
/>
);
const aggregationLabel = i18n.translate(
'xpack.observability.slo.sloEdit.sliType.histogram.aggregationLabel',
{ defaultMessage: 'Aggregation' }
);
const metricLabel = i18n.translate(
'xpack.observability.slo.sloEdit.sliType.histogram.metricLabel',
{ defaultMessage: 'Field' }
);
const toLabel = i18n.translate('xpack.observability.slo.sloEdit.sliType.histogram.toLabel', {
defaultMessage: 'To',
});
const fromLabel = i18n.translate('xpack.observability.slo.sloEdit.sliType.histogram.fromLabel', {
defaultMessage: 'From',
});
return (
<Fragment>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
fullWidth
label={
<span>
{aggregationLabel} {aggregationTooltip}
</span>
}
>
<Controller
name={`indicator.params.${type}.aggregation`}
rules={{ required: true }}
control={control}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiComboBox
{...field}
async
fullWidth
singleSelection={{ asPlainText: true }}
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.histogram.aggregation.placeholder',
{ defaultMessage: 'Select an aggregation' }
)}
aria-label={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.histogram.aggregation.placeholder',
{ defaultMessage: 'Select an aggregation' }
)}
isInvalid={fieldState.invalid}
isDisabled={!indexPattern}
isLoading={!!indexPattern && isLoadingIndex}
onChange={(selected: EuiComboBoxOptionOption[]) => {
if (selected.length) {
return field.onChange(selected[0].value);
}
field.onChange('');
}}
selectedOptions={!!field.value ? [AGGREGATIONS[field.value]] : []}
options={AGGREGATION_OPTIONS}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth label={<span>{metricLabel}</span>}>
<Controller
name={`indicator.params.${type}.field`}
defaultValue=""
rules={{ required: true }}
control={control}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiComboBox
{...field}
async
fullWidth
singleSelection={{ asPlainText: true }}
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.histogram.metricField.placeholder',
{ defaultMessage: 'Select a histogram field' }
)}
aria-label={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.histogram.metricField.placeholder',
{ defaultMessage: 'Select a histogram field' }
)}
isInvalid={fieldState.invalid}
isDisabled={!indexPattern}
isLoading={!!indexPattern && isLoadingIndex}
onChange={(selected: EuiComboBoxOptionOption[]) => {
if (selected.length) {
return field.onChange(selected[0].value);
}
field.onChange('');
}}
selectedOptions={
!!indexPattern &&
!!field.value &&
histogramFields.some((histoField) => histoField.name === field.value)
? [
{
value: field.value,
label: field.value,
},
]
: []
}
options={createOptionsFromFields(histogramFields)}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
{aggregation === 'range' && (
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
fullWidth
label={
<span>
{fromLabel} {fromTooltip}
</span>
}
>
<Controller
name={`indicator.params.${type}.from`}
defaultValue={NaN}
control={control}
rules={{ required: true }}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiFieldNumber
{...field}
required
fullWidth
isInvalid={fieldState.invalid}
value={String(field.value)}
data-test-subj="histogramRangeFrom"
min={0}
onChange={(event) => field.onChange(Number(event.target.value))}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
fullWidth
label={
<span>
{toLabel} {toTooltip}
</span>
}
>
<Controller
name={`indicator.params.${type}.to`}
defaultValue={NaN}
rules={{ required: true }}
control={control}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiFieldNumber
{...field}
required
fullWidth
isInvalid={fieldState.invalid}
value={String(field.value)}
data-test-subj="histogramRangeTo"
min={0}
onChange={(event) => field.onChange(Number(event.target.value))}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
)}
<EuiFlexItem>
<QueryBuilder
dataTestSubj={`histogramIndicatorForm${type}QueryInput`}
indexPatternString={indexPattern}
label={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.histogram.kqlFilterLabel',
{
defaultMessage: 'KQL filter',
}
)}
name={`indicator.params.${type}.filter`}
placeholder=""
required={false}
tooltip={
<EuiIconTip
content={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.histogram.query.tooltip',
{
defaultMessage:
'This KQL query should return a subset of events for this indicator.',
}
)}
position="top"
/>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
);
}

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiComboBox,
EuiComboBoxOptionOption,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIconTip,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { FormattedMessage } from '@kbn/i18n-react';
import { useFetchIndexPatternFields } from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { createOptionsFromFields } from '../../helpers/create_options';
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 { HistogramIndicator } from './histogram_indicator';
export function HistogramIndicatorTypeForm() {
const { control, watch, getFieldState } = useFormContext<CreateSLOForm>();
const index = watch('indicator.params.index');
const { isLoading, data: indexFields } = useFetchIndexPatternFields(index);
const timestampFields = (indexFields ?? []).filter((field) => field.type === 'date');
return (
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup direction="row" gutterSize="l">
<EuiFlexItem>
<IndexSelection />
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.histogram.timestampField.label',
{ defaultMessage: 'Timestamp field' }
)}
isInvalid={getFieldState('indicator.params.timestampField').invalid}
>
<Controller
name="indicator.params.timestampField"
defaultValue=""
rules={{ required: true }}
control={control}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiComboBox
{...field}
async
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.histogram.timestampField.placeholder',
{ defaultMessage: 'Select a timestamp field' }
)}
aria-label={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.histogram.timestampField.placeholder',
{ defaultMessage: 'Select a timestamp field' }
)}
data-test-subj="histogramIndicatorFormTimestampFieldSelect"
isClearable
isDisabled={!index}
isInvalid={fieldState.invalid}
isLoading={!!index && isLoading}
onChange={(selected: EuiComboBoxOptionOption[]) => {
if (selected.length) {
return field.onChange(selected[0].value);
}
field.onChange('');
}}
options={createOptionsFromFields(timestampFields)}
selectedOptions={
!!index &&
!!field.value &&
timestampFields.some((timestampField) => timestampField.name === field.value)
? [{ value: field.value, label: field.value }]
: []
}
singleSelection={{ asPlainText: true }}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem>
<QueryBuilder
dataTestSubj="histogramIndicatorFormQueryFilterInput"
indexPatternString={watch('indicator.params.index')}
label={i18n.translate('xpack.observability.slo.sloEdit.sliType.histogram.queryFilter', {
defaultMessage: 'Query filter',
})}
name="indicator.params.filter"
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.histogram.customFilter',
{ defaultMessage: 'Custom filter to apply on the index' }
)}
tooltip={
<EuiIconTip
content={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.histogram.customFilter.tooltip',
{
defaultMessage:
'This KQL query can be used to filter the documents with some relevant criteria.',
}
)}
position="top"
/>
}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xs">
<h3>
<FormattedMessage
id="xpack.observability.slo.sloEdit.sliType.histogram.goodTitle"
defaultMessage="Good events"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<HistogramIndicator type="good" indexFields={indexFields} isLoadingIndex={isLoading} />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xs">
<h3>
<FormattedMessage
id="xpack.observability.slo.sloEdit.sliType.histogram.totalTitle"
defaultMessage="Total events"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<HistogramIndicator type="total" indexFields={indexFields} isLoadingIndex={isLoading} />
</EuiFlexItem>
<DataPreviewChart />
</EuiFlexGroup>
);
}

View file

@ -16,6 +16,7 @@ import { ApmAvailabilityIndicatorTypeForm } from './apm_availability/apm_availab
import { ApmLatencyIndicatorTypeForm } from './apm_latency/apm_latency_indicator_type_form';
import { CustomKqlIndicatorTypeForm } from './custom_kql/custom_kql_indicator_type_form';
import { CustomMetricIndicatorTypeForm } from './custom_metric/custom_metric_type_form';
import { HistogramIndicatorTypeForm } from './histogram/histogram_indicator_type_form';
import { maxWidth } from './slo_edit_form';
interface SloEditFormIndicatorSectionProps {
@ -36,6 +37,8 @@ export function SloEditFormIndicatorSection({ isEditMode }: SloEditFormIndicator
return <ApmAvailabilityIndicatorTypeForm />;
case 'sli.metric.custom':
return <CustomMetricIndicatorTypeForm />;
case 'sli.histogram.custom':
return <HistogramIndicatorTypeForm />;
default:
return null;
}

View file

@ -10,6 +10,7 @@ import {
APMTransactionDurationIndicator,
APMTransactionErrorRateIndicator,
BudgetingMethod,
HistogramIndicator,
IndicatorType,
KQLCustomIndicator,
MetricCustomIndicator,
@ -22,6 +23,7 @@ import {
INDICATOR_APM_LATENCY,
INDICATOR_CUSTOM_KQL,
INDICATOR_CUSTOM_METRIC,
INDICATOR_HISTOGRAM,
} from '../../utils/slo/labels';
import { CreateSLOForm } from './types';
@ -37,6 +39,10 @@ export const SLI_OPTIONS: Array<{
value: 'sli.metric.custom',
text: INDICATOR_CUSTOM_METRIC,
},
{
value: 'sli.histogram.custom',
text: INDICATOR_HISTOGRAM,
},
{
value: 'sli.apm.transactionDuration',
text: INDICATOR_APM_LATENCY,
@ -118,6 +124,23 @@ export const CUSTOM_METRIC_DEFAULT_VALUES: MetricCustomIndicator = {
},
};
export const HISTOGRAM_DEFAULT_VALUES: HistogramIndicator = {
type: 'sli.histogram.custom' as const,
params: {
index: '',
timestampField: '',
filter: '',
good: {
field: '',
aggregation: 'value_count' as const,
},
total: {
field: '',
aggregation: 'value_count' as const,
},
},
};
export const APM_LATENCY_DEFAULT_VALUES: APMTransactionDurationIndicator = {
type: 'sli.apm.transactionDuration' as const,
params: {

View file

@ -0,0 +1,19 @@
/*
* 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 { Field } from '../../../hooks/slo/use_fetch_index_pattern_fields';
interface Option {
label: string;
value: string;
}
export function createOptionsFromFields(fields: Field[]): Option[] {
return fields
.map((field) => ({ label: field.name, value: field.name }))
.sort((a, b) => String(a.label).localeCompare(b.label));
}

View file

@ -54,6 +54,48 @@ export function useSectionFormValidation({ getFieldState, getValues, formState,
isGoodParamsValid() &&
isTotalParamsValid();
break;
case 'sli.histogram.custom':
const isRangeValid = (type: 'good' | 'total') => {
const aggregation = getValues(`indicator.params.${type}.aggregation`);
// If aggreagtion is a value count we can exit early with true
if (aggregation === 'value_count') {
return true;
}
const from = getValues(`indicator.params.${type}.from`);
const to = getValues(`indicator.params.${type}.to`);
// If both from and to are defined and from is less that to, return true
if (from != null && to != null && from < to) {
return true;
}
return false;
};
isIndicatorSectionValid =
(
[
'indicator.params.index',
'indicator.params.filter',
'indicator.params.timestampField',
'indicator.params.good.aggregation',
'indicator.params.total.aggregation',
'indicator.params.good.field',
'indicator.params.total.field',
'indicator.params.good.filter',
'indicator.params.total.filter',
] as const
).every((field) => !getFieldState(field).invalid) &&
(
[
'indicator.params.good.aggregation',
'indicator.params.total.aggregation',
'indicator.params.good.field',
'indicator.params.total.field',
'indicator.params.index',
'indicator.params.timestampField',
] as const
).every((field) => !!getValues(field)) &&
isRangeValid('good') &&
isRangeValid('total');
break;
case 'sli.kql.custom':
isIndicatorSectionValid =
(

View file

@ -16,6 +16,7 @@ import {
APM_LATENCY_DEFAULT_VALUES,
CUSTOM_KQL_DEFAULT_VALUES,
CUSTOM_METRIC_DEFAULT_VALUES,
HISTOGRAM_DEFAULT_VALUES,
SLO_EDIT_FORM_DEFAULT_VALUES,
} from '../constants';
import { CreateSLOForm } from '../types';
@ -58,6 +59,16 @@ export function useUnregisterFields({ isEditMode }: { isEditMode: boolean }) {
}
);
break;
case 'sli.histogram.custom':
reset(
Object.assign({}, SLO_EDIT_FORM_DEFAULT_VALUES, {
indicator: HISTOGRAM_DEFAULT_VALUES,
}),
{
keepDefaultValues: true,
}
);
break;
case 'sli.apm.transactionDuration':
reset(
Object.assign({}, SLO_EDIT_FORM_DEFAULT_VALUES, {

View file

@ -24,6 +24,7 @@ import {
INDICATOR_APM_LATENCY,
INDICATOR_CUSTOM_KQL,
INDICATOR_CUSTOM_METRIC,
INDICATOR_HISTOGRAM,
} from '../../../utils/slo/labels';
export interface SloListSearchFilterSortBarProps {
@ -38,7 +39,8 @@ export type FilterType =
| 'sli.apm.transactionDuration'
| 'sli.apm.transactionErrorRate'
| 'sli.kql.custom'
| 'sli.metric.custom';
| 'sli.metric.custom'
| 'sli.histogram.custom';
export type Item<T> = EuiSelectableOption & {
label: string;
@ -79,6 +81,10 @@ const INDICATOR_TYPE_OPTIONS: Array<Item<FilterType>> = [
label: INDICATOR_CUSTOM_METRIC,
type: 'sli.metric.custom',
},
{
label: INDICATOR_HISTOGRAM,
type: 'sli.histogram.custom',
},
];
export function SloListSearchFilterSortBar({

View file

@ -21,6 +21,10 @@ export const INDICATOR_CUSTOM_METRIC = i18n.translate(
}
);
export const INDICATOR_HISTOGRAM = i18n.translate('xpack.observability.slo.indicators.histogram', {
defaultMessage: 'Histogram Metric',
});
export const INDICATOR_APM_LATENCY = i18n.translate(
'xpack.observability.slo.indicators.apmLatency',
{ defaultMessage: 'APM latency' }
@ -47,8 +51,11 @@ export function toIndicatorTypeLabel(
case 'sli.metric.custom':
return INDICATOR_CUSTOM_METRIC;
case 'sli.histogram.custom':
return INDICATOR_HISTOGRAM;
default:
assertNever(indicatorType);
assertNever(indicatorType as never);
}
}

View file

@ -32,6 +32,7 @@ import {
import {
ApmTransactionDurationTransformGenerator,
ApmTransactionErrorRateTransformGenerator,
HistogramTransformGenerator,
KQLCustomTransformGenerator,
MetricCustomTransformGenerator,
TransformGenerator,
@ -51,6 +52,7 @@ const transformGenerators: Record<IndicatorTypes, TransformGenerator> = {
'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(),
'sli.kql.custom': new KQLCustomTransformGenerator(),
'sli.metric.custom': new MetricCustomTransformGenerator(),
'sli.histogram.custom': new HistogramTransformGenerator(),
};
const isLicenseAtLeastPlatinum = async (context: ObservabilityRequestHandlerContext) => {

View file

@ -0,0 +1,58 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GetHistogramIndicatorAggregation should generate a aggregation for good events 1`] = `
Object {
"_good": Object {
"aggs": Object {
"total": Object {
"range": Object {
"field": "latency",
"keyed": true,
"ranges": Array [
Object {
"from": 0,
"to": 100,
},
],
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"goodEvents": Object {
"bucket_script": Object {
"buckets_path": Object {
"value": "_good>total['0.0-100.0']>_count",
},
"script": "params.value",
},
},
}
`;
exports[`GetHistogramIndicatorAggregation should generate a aggregation for total events 1`] = `
Object {
"_total": Object {
"aggs": Object {
"total": Object {
"value_count": Object {
"field": "latency",
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"totalEvents": Object {
"bucket_script": Object {
"buckets_path": Object {
"value": "_total>total",
},
"script": "params.value",
},
},
}
`;

View file

@ -0,0 +1,46 @@
/*
* 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 { createHistogramIndicator } from '../fixtures/slo';
import { GetHistogramIndicatorAggregation } from './get_histogram_indicator_aggregation';
describe('GetHistogramIndicatorAggregation', () => {
it('should generate a aggregation for good events', () => {
const getHistogramIndicatorAggregations = new GetHistogramIndicatorAggregation(
createHistogramIndicator()
);
expect(
getHistogramIndicatorAggregations.execute({ type: 'good', aggregationKey: 'goodEvents' })
).toMatchSnapshot();
});
it('should generate a aggregation for total events', () => {
const getHistogramIndicatorAggregations = new GetHistogramIndicatorAggregation(
createHistogramIndicator()
);
expect(
getHistogramIndicatorAggregations.execute({ type: 'total', aggregationKey: 'totalEvents' })
).toMatchSnapshot();
});
it('should throw and error when the "from" is greater than "to"', () => {
const getHistogramIndicatorAggregations = new GetHistogramIndicatorAggregation(
createHistogramIndicator({
good: {
field: 'latency',
aggregation: 'range',
from: 100,
to: 0,
filter: '',
},
})
);
expect(() =>
getHistogramIndicatorAggregations.execute({ type: 'good', aggregationKey: 'goodEvents' })
).toThrow('Invalid Range: "from" should be less that "to".');
});
});

View file

@ -0,0 +1,108 @@
/*
* 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 { HistogramIndicator } from '@kbn/slo-schema';
import { getElastichsearchQueryOrThrow } from '../transform_generators/common';
type HistogramIndicatorDef =
| HistogramIndicator['params']['good']
| HistogramIndicator['params']['total'];
export class GetHistogramIndicatorAggregation {
constructor(private indicator: HistogramIndicator) {}
private buildAggregation(type: 'good' | 'total', indicator: HistogramIndicatorDef) {
const filter = indicator.filter
? getElastichsearchQueryOrThrow(indicator.filter)
: { match_all: {} };
if (indicator.aggregation === 'value_count') {
return {
filter,
aggs: {
total: {
value_count: { field: indicator.field },
},
},
};
}
if (indicator.aggregation === 'range' && (indicator.from == null || indicator.to == null)) {
throw new Error('Invalid Range: both "from" or "to" are required for a range aggregation.');
}
if (
indicator.aggregation === 'range' &&
indicator.from != null &&
indicator.to != null &&
indicator.from >= indicator.to
) {
throw new Error('Invalid Range: "from" should be less that "to".');
}
const range: { from?: number; to?: number } = {};
if (indicator.from != null) {
range.from = indicator.from;
}
if (indicator.to != null) {
range.to = indicator.to;
}
return {
filter,
aggs: {
total: {
range: {
field: indicator.field,
keyed: true,
ranges: [range],
},
},
},
};
}
private formatNumberAsFloatString(value: number) {
return value % 1 === 0 ? `${value}.0` : `${value}`;
}
private buildRangeKey(from: number | undefined, to: number | undefined) {
const fromString = from != null ? this.formatNumberAsFloatString(from) : '*';
const toString = to != null ? this.formatNumberAsFloatString(to) : '*';
return `${fromString}-${toString}`;
}
private buildBucketScript(type: 'good' | 'total', indicator: HistogramIndicatorDef) {
if (indicator.aggregation === 'value_count') {
return {
bucket_script: {
buckets_path: {
value: `_${type}>total`,
},
script: 'params.value',
},
};
}
const rangeKey = this.buildRangeKey(indicator.from, indicator.to);
return {
bucket_script: {
buckets_path: {
value: `_${type}>total['${rangeKey}']>_count`,
},
script: 'params.value',
},
};
}
public execute({ type, aggregationKey }: { type: 'good' | 'total'; aggregationKey: string }) {
const indicatorDef = this.indicator.params[type];
return {
[`_${type}`]: this.buildAggregation(type, indicatorDef),
[aggregationKey]: this.buildBucketScript(type, indicatorDef),
};
}
}

View file

@ -5,4 +5,5 @@
* 2.0.
*/
export * from './get_histogram_indicator_aggregation';
export * from './get_custom_metric_indicator_aggregation';

View file

@ -8,7 +8,7 @@
import { cloneDeep } from 'lodash';
import { v1 as uuidv1 } from 'uuid';
import { SavedObject } from '@kbn/core-saved-objects-server';
import { sloSchema, CreateSLOParams } from '@kbn/slo-schema';
import { sloSchema, CreateSLOParams, HistogramIndicator } from '@kbn/slo-schema';
import { SO_SLO_TYPE } from '../../../saved_objects';
import {
@ -92,6 +92,30 @@ export const createMetricCustomIndicator = (
},
});
export const createHistogramIndicator = (
params: Partial<HistogramIndicator['params']> = {}
): HistogramIndicator => ({
type: 'sli.histogram.custom',
params: {
index: 'my-index*',
filter: 'labels.groupId: group-3',
good: {
field: 'latency',
aggregation: 'range',
from: 0,
to: 100,
filter: '',
},
total: {
field: 'latency',
aggregation: 'value_count',
filter: '',
},
timestampField: 'log_timestamp',
...params,
},
});
const defaultSLO: Omit<SLO, 'id' | 'revision' | 'createdAt' | 'updatedAt'> = {
name: 'irrelevant',
description: 'irrelevant',

View file

@ -7,250 +7,311 @@
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
import { ALL_VALUE, GetPreviewDataParams, GetPreviewDataResponse } from '@kbn/slo-schema';
import {
ALL_VALUE,
APMTransactionErrorRateIndicator,
GetPreviewDataParams,
GetPreviewDataResponse,
HistogramIndicator,
KQLCustomIndicator,
MetricCustomIndicator,
} from '@kbn/slo-schema';
import { APMTransactionDurationIndicator } from '../../domain/models';
import { computeSLI } from '../../domain/services';
import { InvalidQueryError } from '../../errors';
import { GetCustomMetricIndicatorAggregation } from './aggregations';
import {
GetHistogramIndicatorAggregation,
GetCustomMetricIndicatorAggregation,
} from './aggregations';
export class GetPreviewData {
constructor(private esClient: ElasticsearchClient) {}
private async getAPMTransactionDurationPreviewData(
indicator: APMTransactionDurationIndicator
): Promise<GetPreviewDataResponse> {
const filter = [];
if (indicator.params.service !== ALL_VALUE)
filter.push({
match: { 'service.name': indicator.params.service },
});
if (indicator.params.environment !== ALL_VALUE)
filter.push({
match: { 'service.environment': indicator.params.environment },
});
if (indicator.params.transactionName !== ALL_VALUE)
filter.push({
match: { 'transaction.name': indicator.params.transactionName },
});
if (indicator.params.transactionType !== ALL_VALUE)
filter.push({
match: { 'transaction.type': indicator.params.transactionType },
});
if (!!indicator.params.filter)
filter.push(getElastichsearchQueryOrThrow(indicator.params.filter));
const truncatedThreshold = Math.trunc(indicator.params.threshold * 1000);
const result = await this.esClient.search({
index: indicator.params.index,
query: {
bool: {
filter: [
{ range: { '@timestamp': { gte: 'now-60m' } } },
{ terms: { 'processor.event': ['metric'] } },
{ term: { 'metricset.name': 'transaction' } },
{ exists: { field: 'transaction.duration.histogram' } },
...filter,
],
},
},
aggs: {
perMinute: {
date_histogram: {
field: '@timestamp',
fixed_interval: '1m',
},
aggs: {
_good: {
range: {
field: 'transaction.duration.histogram',
ranges: [{ to: truncatedThreshold }],
},
},
good: {
bucket_script: {
buckets_path: {
_good: `_good['*-${truncatedThreshold}.0']>_count`,
},
script: 'params._good',
},
},
total: {
value_count: {
field: 'transaction.duration.histogram',
},
},
},
},
},
});
// @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,
}));
}
private async getAPMTranscationErrorPreviewData(
indicator: APMTransactionErrorRateIndicator
): Promise<GetPreviewDataResponse> {
const filter = [];
if (indicator.params.service !== ALL_VALUE)
filter.push({
match: { 'service.name': indicator.params.service },
});
if (indicator.params.environment !== ALL_VALUE)
filter.push({
match: { 'service.environment': indicator.params.environment },
});
if (indicator.params.transactionName !== ALL_VALUE)
filter.push({
match: { 'transaction.name': indicator.params.transactionName },
});
if (indicator.params.transactionType !== ALL_VALUE)
filter.push({
match: { 'transaction.type': indicator.params.transactionType },
});
if (!!indicator.params.filter)
filter.push(getElastichsearchQueryOrThrow(indicator.params.filter));
const result = await this.esClient.search({
index: indicator.params.index,
query: {
bool: {
filter: [
{ range: { '@timestamp': { gte: 'now-60m' } } },
{ terms: { 'processor.event': ['metric'] } },
{ term: { 'metricset.name': 'transaction' } },
{ exists: { field: 'transaction.duration.histogram' } },
{ exists: { field: 'transaction.result' } },
...filter,
],
},
},
aggs: {
perMinute: {
date_histogram: {
field: '@timestamp',
fixed_interval: '1m',
},
aggs: {
good: {
filter: {
bool: {
should: {
match: {
'event.outcome': 'success',
},
},
},
},
},
total: {
value_count: {
field: 'transaction.duration.histogram',
},
},
},
},
},
});
// @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.doc_count, bucket.total.value)
: null,
}));
}
private async getHistogramPreviewData(
indicator: HistogramIndicator
): Promise<GetPreviewDataResponse> {
const getHistogramIndicatorAggregations = new GetHistogramIndicatorAggregation(indicator);
const filterQuery = getElastichsearchQueryOrThrow(indicator.params.filter);
const timestampField = indicator.params.timestampField;
const options = {
index: indicator.params.index,
query: {
bool: {
filter: [{ range: { [timestampField]: { gte: 'now-60m' } } }, filterQuery],
},
},
aggs: {
perMinute: {
date_histogram: {
field: timestampField,
fixed_interval: '1m',
},
aggs: {
...getHistogramIndicatorAggregations.execute({
type: 'good',
aggregationKey: 'good',
}),
...getHistogramIndicatorAggregations.execute({
type: 'total',
aggregationKey: 'total',
}),
},
},
},
};
const result = await this.esClient.search(options);
// @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,
}));
}
private async getCustomMetricPreviewData(
indicator: MetricCustomIndicator
): Promise<GetPreviewDataResponse> {
const timestampField = indicator.params.timestampField;
const filterQuery = getElastichsearchQueryOrThrow(indicator.params.filter);
const getCustomMetricIndicatorAggregation = new GetCustomMetricIndicatorAggregation(indicator);
const result = await this.esClient.search({
index: 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,
}));
}
private async getCustomKQLPreviewData(
indicator: KQLCustomIndicator
): Promise<GetPreviewDataResponse> {
const filterQuery = getElastichsearchQueryOrThrow(indicator.params.filter);
const goodQuery = getElastichsearchQueryOrThrow(indicator.params.good);
const totalQuery = getElastichsearchQueryOrThrow(indicator.params.total);
const timestampField = indicator.params.timestampField;
const result = await this.esClient.search({
index: indicator.params.index,
query: {
bool: {
filter: [{ range: { [timestampField]: { gte: 'now-60m' } } }, filterQuery],
},
},
aggs: {
perMinute: {
date_histogram: {
field: timestampField,
fixed_interval: '1m',
},
aggs: {
good: { filter: goodQuery },
total: { filter: totalQuery },
},
},
},
});
// @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.doc_count, bucket.total.doc_count)
: null,
}));
}
public async execute(params: GetPreviewDataParams): Promise<GetPreviewDataResponse> {
switch (params.indicator.type) {
case 'sli.apm.transactionDuration':
try {
const filter = [];
if (params.indicator.params.service !== ALL_VALUE)
filter.push({
match: { 'service.name': params.indicator.params.service },
});
if (params.indicator.params.environment !== ALL_VALUE)
filter.push({
match: { 'service.environment': params.indicator.params.environment },
});
if (params.indicator.params.transactionName !== ALL_VALUE)
filter.push({
match: { 'transaction.name': params.indicator.params.transactionName },
});
if (params.indicator.params.transactionType !== ALL_VALUE)
filter.push({
match: { 'transaction.type': params.indicator.params.transactionType },
});
if (!!params.indicator.params.filter)
filter.push(getElastichsearchQueryOrThrow(params.indicator.params.filter));
const truncatedThreshold = Math.trunc(params.indicator.params.threshold * 1000);
const result = await this.esClient.search({
index: params.indicator.params.index,
query: {
bool: {
filter: [
{ range: { '@timestamp': { gte: 'now-60m' } } },
{ terms: { 'processor.event': ['metric'] } },
{ term: { 'metricset.name': 'transaction' } },
{ exists: { field: 'transaction.duration.histogram' } },
...filter,
],
},
},
aggs: {
perMinute: {
date_histogram: {
field: '@timestamp',
fixed_interval: '1m',
},
aggs: {
_good: {
range: {
field: 'transaction.duration.histogram',
ranges: [{ to: truncatedThreshold }],
},
},
good: {
bucket_script: {
buckets_path: {
_good: `_good['*-${truncatedThreshold}.0']>_count`,
},
script: 'params._good',
},
},
total: {
value_count: {
field: 'transaction.duration.histogram',
},
},
},
},
},
});
// @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,
}));
} catch (err) {
throw new InvalidQueryError(`Invalid ES query`);
}
return this.getAPMTransactionDurationPreviewData(params.indicator);
case 'sli.apm.transactionErrorRate':
try {
const filter = [];
if (params.indicator.params.service !== ALL_VALUE)
filter.push({
match: { 'service.name': params.indicator.params.service },
});
if (params.indicator.params.environment !== ALL_VALUE)
filter.push({
match: { 'service.environment': params.indicator.params.environment },
});
if (params.indicator.params.transactionName !== ALL_VALUE)
filter.push({
match: { 'transaction.name': params.indicator.params.transactionName },
});
if (params.indicator.params.transactionType !== ALL_VALUE)
filter.push({
match: { 'transaction.type': params.indicator.params.transactionType },
});
if (!!params.indicator.params.filter)
filter.push(getElastichsearchQueryOrThrow(params.indicator.params.filter));
const result = await this.esClient.search({
index: params.indicator.params.index,
query: {
bool: {
filter: [
{ range: { '@timestamp': { gte: 'now-60m' } } },
{ terms: { 'processor.event': ['metric'] } },
{ term: { 'metricset.name': 'transaction' } },
{ exists: { field: 'transaction.duration.histogram' } },
{ exists: { field: 'transaction.result' } },
...filter,
],
},
},
aggs: {
perMinute: {
date_histogram: {
field: '@timestamp',
fixed_interval: '1m',
},
aggs: {
good: {
filter: {
bool: {
should: {
match: {
'event.outcome': 'success',
},
},
},
},
},
total: {
value_count: {
field: 'transaction.duration.histogram',
},
},
},
},
},
});
// @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.doc_count, bucket.total.value)
: null,
}));
} catch (err) {
throw new InvalidQueryError(`Invalid ES query`);
}
return this.getAPMTranscationErrorPreviewData(params.indicator);
case 'sli.kql.custom':
try {
const filterQuery = getElastichsearchQueryOrThrow(params.indicator.params.filter);
const goodQuery = getElastichsearchQueryOrThrow(params.indicator.params.good);
const totalQuery = getElastichsearchQueryOrThrow(params.indicator.params.total);
const timestampField = params.indicator.params.timestampField;
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: {
good: { filter: goodQuery },
total: { filter: totalQuery },
},
},
},
});
// @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.doc_count, bucket.total.doc_count)
: null,
}));
} catch (err) {
throw new InvalidQueryError(`Invalid ES query`);
}
return this.getCustomKQLPreviewData(params.indicator);
case 'sli.histogram.custom':
return this.getHistogramPreviewData(params.indicator);
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,
}));
return this.getCustomMetricPreviewData(params.indicator);
default:
return [];
}

View file

@ -0,0 +1,317 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Histogram Transform Generator aggregates using the denominator equation 1`] = `
Object {
"bucket_script": Object {
"buckets_path": Object {
"value": "_total>total",
},
"script": "params.value",
},
}
`;
exports[`Histogram Transform Generator aggregates using the denominator equation with filter 1`] = `
Object {
"bucket_script": Object {
"buckets_path": Object {
"value": "_total>total",
},
"script": "params.value",
},
}
`;
exports[`Histogram Transform Generator aggregates using the numerator equation 1`] = `
Object {
"bucket_script": Object {
"buckets_path": Object {
"value": "_good>total['0.0-100.0']>_count",
},
"script": "params.value",
},
}
`;
exports[`Histogram Transform Generator aggregates using the numerator equation with filter 1`] = `
Object {
"bucket_script": Object {
"buckets_path": Object {
"value": "_good>total['0.0-100.0']>_count",
},
"script": "params.value",
},
}
`;
exports[`Histogram Transform Generator filters the source using the kql query 1`] = `
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"labels.groupId": "group-4",
},
},
],
},
}
`;
exports[`Histogram Transform Generator returns the expected transform params for timeslices slo 1`] = `
Object {
"_meta": Object {
"managed": true,
"managed_by": "observability",
"version": 1,
},
"description": "Rolled-up SLI data for SLO: irrelevant",
"dest": Object {
"index": ".slo-observability.sli-v1",
"pipeline": ".slo-observability.sli.monthly",
},
"frequency": "1m",
"pivot": Object {
"aggregations": Object {
"_good": Object {
"aggs": Object {
"total": Object {
"range": Object {
"field": "latency",
"keyed": true,
"ranges": Array [
Object {
"from": 0,
"to": 100,
},
],
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"_total": Object {
"aggs": Object {
"total": Object {
"value_count": Object {
"field": "latency",
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"slo.denominator": Object {
"bucket_script": Object {
"buckets_path": Object {
"value": "_total>total",
},
"script": "params.value",
},
},
"slo.isGoodSlice": Object {
"bucket_script": Object {
"buckets_path": Object {
"goodEvents": "slo.numerator>value",
"totalEvents": "slo.denominator>value",
},
"script": "params.goodEvents / params.totalEvents >= 0.95 ? 1 : 0",
},
},
"slo.numerator": Object {
"bucket_script": Object {
"buckets_path": Object {
"value": "_good>total['0.0-100.0']>_count",
},
"script": "params.value",
},
},
},
"group_by": Object {
"@timestamp": Object {
"date_histogram": Object {
"field": "log_timestamp",
"fixed_interval": "2m",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
},
},
"settings": Object {
"deduce_mappings": false,
},
"source": Object {
"index": "my-index*",
"query": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"labels.groupId": "group-3",
},
},
],
},
},
"runtime_mappings": Object {
"slo.id": Object {
"script": Object {
"source": Any<String>,
},
"type": "keyword",
},
"slo.revision": Object {
"script": Object {
"source": "emit(1)",
},
"type": "long",
},
},
},
"sync": Object {
"time": Object {
"delay": "1m",
"field": "log_timestamp",
},
},
"transform_id": Any<String>,
}
`;
exports[`Histogram Transform Generator returns the expected transform params with every specified indicator params 1`] = `
Object {
"_meta": Object {
"managed": true,
"managed_by": "observability",
"version": 1,
},
"description": "Rolled-up SLI data for SLO: irrelevant",
"dest": Object {
"index": ".slo-observability.sli-v1",
"pipeline": ".slo-observability.sli.monthly",
},
"frequency": "1m",
"pivot": Object {
"aggregations": Object {
"_good": Object {
"aggs": Object {
"total": Object {
"range": Object {
"field": "latency",
"keyed": true,
"ranges": Array [
Object {
"from": 0,
"to": 100,
},
],
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"_total": Object {
"aggs": Object {
"total": Object {
"value_count": Object {
"field": "latency",
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"slo.denominator": Object {
"bucket_script": Object {
"buckets_path": Object {
"value": "_total>total",
},
"script": "params.value",
},
},
"slo.numerator": Object {
"bucket_script": Object {
"buckets_path": Object {
"value": "_good>total['0.0-100.0']>_count",
},
"script": "params.value",
},
},
},
"group_by": Object {
"@timestamp": Object {
"date_histogram": Object {
"field": "log_timestamp",
"fixed_interval": "1m",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
},
},
"settings": Object {
"deduce_mappings": false,
},
"source": Object {
"index": "my-index*",
"query": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"labels.groupId": "group-3",
},
},
],
},
},
"runtime_mappings": Object {
"slo.id": Object {
"script": Object {
"source": Any<String>,
},
"type": "keyword",
},
"slo.revision": Object {
"script": Object {
"source": "emit(1)",
},
"type": "long",
},
},
},
"sync": Object {
"time": Object {
"delay": "1m",
"field": "log_timestamp",
},
},
"transform_id": Any<String>,
}
`;

View file

@ -0,0 +1,162 @@
/*
* 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 {
createHistogramIndicator,
createSLO,
createSLOWithTimeslicesBudgetingMethod,
} from '../fixtures/slo';
import { HistogramTransformGenerator } from './histogram';
const generator = new HistogramTransformGenerator();
describe('Histogram Transform Generator', () => {
describe('validation', () => {
it('throws when the good filter is invalid', () => {
const anSLO = createSLO({
indicator: createHistogramIndicator({
good: {
field: 'latency',
aggregation: 'range',
from: 0,
to: 100,
filter: 'foo:',
},
}),
});
expect(() => generator.getTransformParams(anSLO)).toThrow(/Invalid KQL: foo:/);
});
it('throws when the total filter is invalid', () => {
const anSLO = createSLO({
indicator: createHistogramIndicator({
good: {
field: 'latency',
aggregation: 'value_count',
filter: 'foo:',
},
}),
});
expect(() => generator.getTransformParams(anSLO)).toThrow(/Invalid KQL: foo:/);
});
it('throws when the query_filter is invalid', () => {
const anSLO = createSLO({
indicator: createHistogramIndicator({ filter: '{ kql.query: invalid' }),
});
expect(() => generator.getTransformParams(anSLO)).toThrow(/Invalid KQL/);
});
});
it('returns the expected transform params with every specified indicator params', async () => {
const anSLO = createSLO({ indicator: createHistogramIndicator() });
const transform = generator.getTransformParams(anSLO);
expect(transform).toMatchSnapshot({
transform_id: expect.any(String),
source: { runtime_mappings: { 'slo.id': { script: { source: expect.any(String) } } } },
});
expect(transform.transform_id).toEqual(`slo-${anSLO.id}-${anSLO.revision}`);
expect(transform.source.runtime_mappings!['slo.id']).toMatchObject({
script: { source: `emit('${anSLO.id}')` },
});
expect(transform.source.runtime_mappings!['slo.revision']).toMatchObject({
script: { source: `emit(${anSLO.revision})` },
});
});
it('returns the expected transform params for timeslices slo', async () => {
const anSLO = createSLOWithTimeslicesBudgetingMethod({
indicator: createHistogramIndicator(),
});
const transform = generator.getTransformParams(anSLO);
expect(transform).toMatchSnapshot({
transform_id: expect.any(String),
source: { runtime_mappings: { 'slo.id': { script: { source: expect.any(String) } } } },
});
});
it('filters the source using the kql query', async () => {
const anSLO = createSLO({
indicator: createHistogramIndicator({ filter: 'labels.groupId: group-4' }),
});
const transform = generator.getTransformParams(anSLO);
expect(transform.source.query).toMatchSnapshot();
});
it('uses the provided index', async () => {
const anSLO = createSLO({
indicator: createHistogramIndicator({ index: 'my-own-index*' }),
});
const transform = generator.getTransformParams(anSLO);
expect(transform.source.index).toBe('my-own-index*');
});
it('uses the provided timestampField', async () => {
const anSLO = createSLO({
indicator: createHistogramIndicator({
timestampField: 'my-date-field',
}),
});
const transform = generator.getTransformParams(anSLO);
expect(transform.sync?.time?.field).toBe('my-date-field');
// @ts-ignore
expect(transform.pivot?.group_by['@timestamp'].date_histogram.field).toBe('my-date-field');
});
it('aggregates using the numerator equation', async () => {
const anSLO = createSLO({
indicator: createHistogramIndicator(),
});
const transform = generator.getTransformParams(anSLO);
expect(transform.pivot!.aggregations!['slo.numerator']).toMatchSnapshot();
});
it('aggregates using the numerator equation with filter', async () => {
const anSLO = createSLO({
indicator: createHistogramIndicator({
good: {
field: 'latency',
aggregation: 'range',
from: 0,
to: 100,
filter: 'foo: "bar"',
},
}),
});
const transform = generator.getTransformParams(anSLO);
expect(transform.pivot!.aggregations!['slo.numerator']).toMatchSnapshot();
});
it('aggregates using the denominator equation', async () => {
const anSLO = createSLO({
indicator: createHistogramIndicator(),
});
const transform = generator.getTransformParams(anSLO);
expect(transform.pivot!.aggregations!['slo.denominator']).toMatchSnapshot();
});
it('aggregates using the denominator equation with filter', async () => {
const anSLO = createSLO({
indicator: createHistogramIndicator({
total: {
field: 'latency',
aggregation: 'value_count',
filter: 'foo: "bar"',
},
}),
});
const transform = generator.getTransformParams(anSLO);
expect(transform.pivot!.aggregations!['slo.denominator']).toMatchSnapshot();
});
});

View file

@ -0,0 +1,88 @@
/*
* 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 { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types';
import {
HistogramIndicator,
histogramIndicatorSchema,
timeslicesBudgetingMethodSchema,
} from '@kbn/slo-schema';
import { InvalidTransformError } from '../../../errors';
import { getSLOTransformTemplate } from '../../../assets/transform_templates/slo_transform_template';
import { getElastichsearchQueryOrThrow, TransformGenerator } from '.';
import {
SLO_DESTINATION_INDEX_NAME,
SLO_INGEST_PIPELINE_NAME,
getSLOTransformId,
} from '../../../assets/constants';
import { SLO } from '../../../domain/models';
import { GetHistogramIndicatorAggregation } from '../aggregations';
export class HistogramTransformGenerator extends TransformGenerator {
public getTransformParams(slo: SLO): TransformPutTransformRequest {
if (!histogramIndicatorSchema.is(slo.indicator)) {
throw new InvalidTransformError(`Cannot handle SLO of indicator type: ${slo.indicator.type}`);
}
return getSLOTransformTemplate(
this.buildTransformId(slo),
this.buildDescription(slo),
this.buildSource(slo, slo.indicator),
this.buildDestination(),
this.buildGroupBy(slo, slo.indicator.params.timestampField),
this.buildAggregations(slo, slo.indicator),
this.buildSettings(slo, slo.indicator.params.timestampField)
);
}
private buildTransformId(slo: SLO): string {
return getSLOTransformId(slo.id, slo.revision);
}
private buildSource(slo: SLO, indicator: HistogramIndicator) {
const filter = getElastichsearchQueryOrThrow(indicator.params.filter);
return {
index: indicator.params.index,
runtime_mappings: this.buildCommonRuntimeMappings(slo),
query: filter,
};
}
private buildDestination() {
return {
pipeline: SLO_INGEST_PIPELINE_NAME,
index: SLO_DESTINATION_INDEX_NAME,
};
}
private buildAggregations(slo: SLO, indicator: HistogramIndicator) {
const getHistogramIndicatorAggregations = new GetHistogramIndicatorAggregation(indicator);
return {
...getHistogramIndicatorAggregations.execute({
type: 'good',
aggregationKey: 'slo.numerator',
}),
...getHistogramIndicatorAggregations.execute({
type: 'total',
aggregationKey: 'slo.denominator',
}),
...(timeslicesBudgetingMethodSchema.is(slo.budgetingMethod) && {
'slo.isGoodSlice': {
bucket_script: {
buckets_path: {
goodEvents: 'slo.numerator>value',
totalEvents: 'slo.denominator>value',
},
script: `params.goodEvents / params.totalEvents >= ${slo.objective.timesliceTarget} ? 1 : 0`,
},
},
}),
};
}
}

View file

@ -10,4 +10,5 @@ export * from './apm_transaction_error_rate';
export * from './apm_transaction_duration';
export * from './kql_custom';
export * from './metric_custom';
export * from './histogram';
export * from './common';