[SLO] Introduce Custom Metric SLI (#157421)

This commit is contained in:
Chris Cowan 2023-05-21 19:11:21 -06:00 committed by GitHub
parent d6b29afa2b
commit ab9d3191c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1478 additions and 37 deletions

View file

@ -21,6 +21,10 @@ import {
summarySchema,
tagsSchema,
timeWindowSchema,
metricCustomIndicatorSchema,
kqlCustomIndicatorSchema,
apmTransactionErrorRateIndicatorSchema,
apmTransactionDurationIndicatorSchema,
} from '../schema';
const createSLOParamsSchema = t.type({
@ -155,6 +159,13 @@ type HistoricalSummaryResponse = t.OutputOf<typeof historicalSummarySchema>;
type BudgetingMethod = t.TypeOf<typeof budgetingMethodSchema>;
type MetricCustomIndicatorSchema = t.TypeOf<typeof metricCustomIndicatorSchema>;
type KQLCustomIndicatorSchema = t.TypeOf<typeof kqlCustomIndicatorSchema>;
type APMTransactionErrorRateIndicatorSchema = t.TypeOf<
typeof apmTransactionErrorRateIndicatorSchema
>;
type APMTransactionDurationIndicatorSchema = t.TypeOf<typeof apmTransactionDurationIndicatorSchema>;
export {
createSLOParamsSchema,
deleteSLOParamsSchema,
@ -188,4 +199,8 @@ export type {
UpdateSLOInput,
UpdateSLOParams,
UpdateSLOResponse,
MetricCustomIndicatorSchema,
KQLCustomIndicatorSchema,
APMTransactionDurationIndicatorSchema,
APMTransactionErrorRateIndicatorSchema,
};

View file

@ -56,6 +56,31 @@ const kqlCustomIndicatorSchema = t.type({
}),
});
const metricCustomValidAggregations = t.keyof({
sum: true,
});
const metricCustomMetricDef = t.type({
metrics: t.array(
t.type({
name: t.string,
aggregation: metricCustomValidAggregations,
field: t.string,
})
),
equation: t.string,
});
const metricCustomIndicatorTypeSchema = t.literal('sli.metric.custom');
const metricCustomIndicatorSchema = t.type({
type: metricCustomIndicatorTypeSchema,
params: t.type({
index: t.string,
filter: t.string,
good: metricCustomMetricDef,
total: metricCustomMetricDef,
timestampField: t.string,
}),
});
const indicatorDataSchema = t.type({
dateRange: dateRangeSchema,
good: t.number,
@ -66,6 +91,7 @@ const indicatorTypesSchema = t.union([
apmTransactionDurationIndicatorTypeSchema,
apmTransactionErrorRateIndicatorTypeSchema,
kqlCustomIndicatorTypeSchema,
metricCustomIndicatorTypeSchema,
]);
// Validate that a string is a comma separated list of indicator types,
@ -91,6 +117,7 @@ const indicatorSchema = t.union([
apmTransactionDurationIndicatorSchema,
apmTransactionErrorRateIndicatorSchema,
kqlCustomIndicatorSchema,
metricCustomIndicatorSchema,
]);
export {
@ -100,6 +127,8 @@ export {
apmTransactionErrorRateIndicatorTypeSchema,
kqlCustomIndicatorSchema,
kqlCustomIndicatorTypeSchema,
metricCustomIndicatorTypeSchema,
metricCustomIndicatorSchema,
indicatorSchema,
indicatorTypesArraySchema,
indicatorTypesSchema,

View file

@ -6,4 +6,5 @@
*/
export const SLO_BURN_RATE_RULE_ID = 'slo.rules.burnRate';
export const INVALID_EQUATION_REGEX = /[^A-Z|+|\-|\s|\d+|\.|\(|\)|\/|\*|>|<|=|\?|\:|&|\!|\|]+/g;
export const ALERT_STATUS_ALL = 'all';

View file

@ -9,11 +9,14 @@ We currently support the following SLI:
- APM Transaction Error Rate, known as APM Availability
- APM Transaction Duration, known as APM Latency
- Custom KQL
- Custom Metric
For the APM SLIs, customer can provide the service, environment, transaction name and type to configure them. For the **APM Latency** SLI, a threshold in milliseconds needs to be provided to discriminate the good and bad responses (events). For the **APM Availability** SLI, we use the `event.outcome` as a way to discriminate the good and the bad responses(events). The API supports an optional kql filter to further filter the apm data.
The **custom KQL** SLI requires an index pattern, an optional filter query, a numerator query, and denominator query. A custom 'timestampField' can be provided to override the default @timestamp field.
The **custom Metric** SLI requires an index pattern, an optional filter query, a set of metrics for the numerator, and a set of metrics for the denominator. A custom 'timestampField' can be provided to override the default @timestamp field.
## SLO configuration
### Time window
@ -271,7 +274,7 @@ curl --request POST \
</details>
### Custom
### Custom KQL
<details>
<summary>98.5% of 'logs lantency < 300ms' for 'groupId: group-0' over the last 7 days</summary>
@ -307,3 +310,58 @@ curl --request POST \
```
</details>
### Custom Metric
<details>
<summary>95.0% of events are processed over the last 7 days</summary>
```
curl --request POST \
--url http://localhost:5601/cyp/api/observability/slos \
--header 'Authorization: Basic ZWxhc3RpYzpjaGFuZ2VtZQ==' \
--header 'Content-Type: application/json' \
--header 'kbn-xsrf: oui' \
--data '{
"name": "My SLO Name",
"description": "My SLO Description",
"indicator": {
"type": "sli.metric.custom",
"params": {
"index": "high-cardinality-data-fake_stack.message_processor-*",
"good": {
"metrics": [
{
"name": "A",
"aggregation": "sum",
"field": "processor.processed"
}
],
equation: 'A'
},
"total": {
"metrics": [
{
"name": "A",
"aggregation": "sum",
"processor.accepted"
}
],
equation: 'A'
},
"filter": "",
"timestampField": "@timestamp"
}
},
"timeWindow": {
"duration": "7d",
"isRolling": true
},
"budgetingMethod": "occurrences",
"objective": {
"target": 0.95
}
}'
```
</details>

View file

@ -25,6 +25,7 @@ properties:
- $ref: 'indicator_properties_custom_kql.yaml'
- $ref: 'indicator_properties_apm_availability.yaml'
- $ref: 'indicator_properties_apm_latency.yaml'
- $ref: 'indicator_properties_custom_metric.yaml'
timeWindow:
oneOf:
- $ref: 'time_window_rolling.yaml'

View file

@ -31,7 +31,7 @@ properties:
example: ''
timestampField:
description: >
The timestamp field used in the source indice. If not specified, @timestamp will be used.
The timestamp field used in the source indice.
type: string
example: timestamp
type:

View file

@ -0,0 +1,106 @@
title: Custom metric indicator type definition
required:
- type
- params
description: Defines properties for a custom metric 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" metrics and equation
type: object
required:
- metrics
- equation
properties:
metrics:
description: List of metrics with their name, aggregation type, and field.
type: array
items:
type: object
required:
- name
- aggregation
- field
properties:
name:
description: The name of the metric. Only valid options are A-Z
type: string
example: A
pattern: '^[A-Z]$'
aggregation:
description: The aggregation type of the metric. Only valid option is "sum"
type: string
example: sum
enum: [sum]
field:
description: The field of the metric.
type: string
example: processor.processed
equation:
description: The equation to calculate the "good" metric.
type: string
example: A
total:
description: >
An object defining the "total" metrics and equation
type: object
required:
- metrics
- equation
properties:
metrics:
description: List of metrics with their name, aggregation type, and field.
type: array
items:
type: object
required:
- name
- aggregation
- field
properties:
name:
description: The name of the metric. Only valid options are A-Z
type: string
example: A
pattern: '^[A-Z]$'
aggregation:
description: The aggregation type of the metric. Only valid option is "sum"
type: string
example: sum
enum: [sum]
field:
description: The field of the metric.
type: string
example: processor.processed
equation:
description: The equation to calculate the "total" metric.
type: string
example: A
type:
description: The type of indicator.
type: string
example: sli.metric.custom

View file

@ -18,6 +18,7 @@ properties:
- $ref: 'indicator_properties_custom_kql.yaml'
- $ref: 'indicator_properties_apm_availability.yaml'
- $ref: 'indicator_properties_apm_latency.yaml'
- $ref: 'indicator_properties_custom_metric.yaml'
timeWindow:
oneOf:
- $ref: 'time_window_rolling.yaml'
@ -28,7 +29,7 @@ properties:
$ref: 'objective.yaml'
settings:
$ref: 'settings.yaml'
revision:
revision:
description: The SLO revision
type: number
example: 2
@ -45,4 +46,4 @@ properties:
updatedAt:
description: The last update date
type: string
example: "2023-01-12T10:03:19.000Z"
example: "2023-01-12T10:03:19.000Z"

View file

@ -1,6 +1,6 @@
title: Update SLO request
description: >
The update SLO API request body varies depending on the type of indicator, time window and budgeting method.
The update SLO API request body varies depending on the type of indicator, time window and budgeting method.
Partial update is handled.
type: object
properties:
@ -15,6 +15,7 @@ properties:
- $ref: 'indicator_properties_custom_kql.yaml'
- $ref: 'indicator_properties_apm_availability.yaml'
- $ref: 'indicator_properties_apm_latency.yaml'
- $ref: 'indicator_properties_custom_metric.yaml'
timeWindow:
oneOf:
- $ref: 'time_window_rolling.yaml'

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { KQLCustomIndicatorSchema, SLOWithSummaryResponse } from '@kbn/slo-schema';
export const buildApmAvailabilityIndicator = (
params: Partial<SLOWithSummaryResponse['indicator']['params']> = {}
@ -53,5 +53,5 @@ export const buildCustomKqlIndicator = (
timestampField: '@timestamp',
...params,
},
};
} as KQLCustomIndicatorSchema;
};

View file

@ -22,7 +22,7 @@ import {
Field,
useFetchIndexPatternFields,
} from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { IndexSelection } from './index_selection';
import { IndexSelection } from '../custom_common/index_selection';
import { QueryBuilder } from '../common/query_builder';
interface Option {

View file

@ -0,0 +1,34 @@
/*
* 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 from 'react';
import { ComponentStory } from '@storybook/react';
import { FormProvider, useForm } from 'react-hook-form';
import { KibanaReactStorybookDecorator } from '../../../../utils/kibana_react.storybook_decorator';
import { CustomMetricIndicatorTypeForm as Component } from './custom_metric_type_form';
import { SLO_EDIT_FORM_DEFAULT_VALUES_CUSTOM_METRIC } from '../../constants';
export default {
component: Component,
title: 'app/SLO/EditPage/CustomMetric/Form',
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = () => {
const methods = useForm({ defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES_CUSTOM_METRIC });
return (
<FormProvider {...methods}>
<Component />
</FormProvider>
);
};
const defaultProps = {};
export const Form = Template.bind({});
Form.args = defaultProps;

View file

@ -0,0 +1,226 @@
/*
* 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 from 'react';
import {
EuiComboBox,
EuiComboBoxOptionOption,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIconTip,
EuiPanel,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Controller, useFormContext } from 'react-hook-form';
import { CreateSLOInput } from '@kbn/slo-schema';
import {
Field,
useFetchIndexPatternFields,
} from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { IndexSelection } from '../custom_common/index_selection';
import { QueryBuilder } from '../common/query_builder';
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<CreateSLOInput>();
const { isLoading, data: indexFields } = useFetchIndexPatternFields(
watch('indicator.params.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.customMetric.timestampField.label',
{ defaultMessage: 'Timestamp field' }
)}
isInvalid={getFieldState('indicator.params.timestampField').invalid}
>
<Controller
name="indicator.params.timestampField"
shouldUnregister
defaultValue=""
rules={{ required: true }}
control={control}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiComboBox
{...field}
async
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.timestampField.placeholder',
{ defaultMessage: 'Select a timestamp field' }
)}
aria-label={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.timestampField.placeholder',
{ defaultMessage: 'Select a timestamp field' }
)}
data-test-subj="customMetricIndicatorFormTimestampFieldSelect"
isClearable
isDisabled={!watch('indicator.params.index')}
isInvalid={fieldState.invalid}
isLoading={!!watch('indicator.params.index') && isLoading}
onChange={(selected: EuiComboBoxOptionOption[]) => {
if (selected.length) {
return field.onChange(selected[0].value);
}
field.onChange('');
}}
options={createOptions(timestampFields)}
selectedOptions={
!!watch('indicator.params.index') &&
!!field.value &&
timestampFields.some((timestampField) => timestampField.name === field.value)
? [
{
value: field.value,
label: field.value,
'data-test-subj': `customMetricIndicatorFormTimestampFieldSelectedValue`,
},
]
: []
}
singleSelection={{ asPlainText: true }}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem>
<QueryBuilder
control={control}
dataTestSubj="customMetricIndicatorFormQueryFilterInput"
indexPatternString={watch('indicator.params.index')}
label={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.queryFilter',
{
defaultMessage: 'Query filter',
}
)}
name="indicator.params.filter"
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.customFilter',
{ defaultMessage: 'Custom filter to apply on the index' }
)}
tooltip={
<EuiIconTip
content={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.customFilter.tooltip',
{
defaultMessage:
'This KQL query can be used to filter the documents with some relevant criteria.',
}
)}
position="top"
/>
}
/>
</EuiFlexItem>
<EuiPanel hasBorder={true}>
<MetricIndicator
type="good"
indexFields={indexFields}
isLoadingIndex={isLoading}
equationLabel={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.goodEquationLabel',
{ defaultMessage: 'Good equation' }
)}
metricLabel={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.goodMetricLabel',
{ defaultMessage: 'Good metric' }
)}
metricTooltip={
<EuiIconTip
content={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.goodMetric.tooltip',
{
defaultMessage:
'This data from this field will be aggregated with the "sum" aggregation.',
}
)}
position="top"
/>
}
equationTooltip={
<EuiIconTip
content={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.goodEquation.tooltip',
{
defaultMessage:
'This supports basic math (A + B / C) and boolean logic (A < B ? A : B).',
}
)}
position="top"
/>
}
/>
</EuiPanel>
<EuiPanel hasBorder={true}>
<MetricIndicator
type="total"
indexFields={indexFields}
isLoadingIndex={isLoading}
equationLabel={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.totalEquationLabel',
{ defaultMessage: 'Total equation' }
)}
metricLabel={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.totalMetricLabel',
{ defaultMessage: 'Total metric' }
)}
metricTooltip={
<EuiIconTip
content={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.totalMetric.tooltip',
{
defaultMessage:
'This data from this field will be aggregated with the "sum" aggregation.',
}
)}
position="top"
/>
}
equationTooltip={
<EuiIconTip
content={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.totalEquation.tooltip',
{
defaultMessage:
'This supports basic math (A + B / C) and boolean logic (A < B ? A : B).',
}
)}
position="top"
/>
}
/>
</EuiPanel>
</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

@ -0,0 +1,277 @@
/*
* 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, { ReactNode, useEffect } from 'react';
import {
EuiButtonEmpty,
EuiButtonIcon,
EuiComboBox,
EuiComboBoxOptionOption,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Controller, useFormContext, useFieldArray } from 'react-hook-form';
import { CreateSLOInput } from '@kbn/slo-schema';
import { range, first, xor } from 'lodash';
import { FormattedMessage } from '@kbn/i18n-react';
import { Field } from '../../../../hooks/slo/use_fetch_index_pattern_fields';
interface Option {
label: string;
value: string;
}
interface MetricIndicatorProps {
type: 'good' | 'total';
indexFields: Field[] | undefined;
isLoadingIndex: boolean;
metricLabel: string;
equationLabel: string;
metricTooltip: ReactNode;
equationTooltip: ReactNode;
}
export const NEW_CUSTOM_METRIC = { name: 'A', aggregation: 'sum' as const, field: '' };
const MAX_VARIABLES = 26;
const CHAR_CODE_FOR_A = 65;
const CHAR_CODE_FOR_Z = CHAR_CODE_FOR_A + MAX_VARIABLES;
const VAR_NAMES = range(CHAR_CODE_FOR_A, CHAR_CODE_FOR_Z).map((c) => String.fromCharCode(c));
const INVALID_EQUATION_REGEX = /[^A-Z|+|\-|\s|\d+|\.|\(|\)|\/|\*|>|<|=|\?|\:|&|\!|\|]+/;
const validateEquation = (value: string) => {
const result = value.match(INVALID_EQUATION_REGEX);
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(' + ');
}
export function MetricIndicator({
type,
indexFields,
isLoadingIndex,
metricLabel,
equationLabel,
metricTooltip,
equationTooltip,
}: MetricIndicatorProps) {
const { control, watch, setValue } = useFormContext<CreateSLOInput>();
const metricFields = (indexFields ?? []).filter((field) => field.type === 'number');
const {
fields: metrics,
append,
remove,
} = useFieldArray({
control,
name: `indicator.params.${type}.metrics`,
});
const equation = watch(`indicator.params.${type}.equation`);
const indexPattern = watch('indicator.params.index');
// Without this, the hidden fields for metric.name and metric.aggregation will
// not be included in the JSON when the form is submitted.
useEffect(() => {
metrics.forEach((metric, index) => {
setValue(`indicator.params.${type}.metrics.${index}.name`, metric.name);
setValue(`indicator.params.${type}.metrics.${index}.aggregation`, metric.aggregation);
});
}, [metrics, setValue, type]);
const disableAdd = metrics?.length === MAX_VARIABLES;
const disableDelete = metrics?.length === 1;
const setDefaultEquationIfUnchanged = (previousNames: string[], nextNames: string[]) => {
const defaultEquation = createEquationFromMetric(previousNames);
if (defaultEquation === equation) {
setValue(`indicator.params.${type}.equation`, createEquationFromMetric(nextNames));
}
};
const handleDeleteMetric = (index: number) => () => {
const currentVars = metrics.map((m) => m.name) ?? ['A'];
const deletedVar = currentVars[index];
setDefaultEquationIfUnchanged(currentVars, xor(currentVars, [deletedVar]));
remove(index);
};
const handleAddMetric = () => {
const currentVars = metrics.map((m) => m.name) ?? ['A'];
const name = first(xor(VAR_NAMES, currentVars))!;
setDefaultEquationIfUnchanged(currentVars, [...currentVars, name]);
append({ ...NEW_CUSTOM_METRIC, name });
};
return (
<>
<EuiFlexItem>
{metrics?.map((metric, index) => (
<EuiFormRow
fullWidth
label={
<span>
{metricLabel} {metric.name} {metricTooltip}
</span>
}
key={metric.id}
>
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem>
<Controller
name={`indicator.params.${type}.metrics.${index}.field`}
shouldUnregister
defaultValue=""
rules={{ required: true }}
control={control}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiComboBox
{...field}
async
fullWidth
singleSelection={{ asPlainText: true }}
prepend={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.sumLabel',
{ defaultMessage: 'Sum of' }
)}
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.metricField.placeholder',
{ defaultMessage: 'Select a metric field' }
)}
aria-label={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.metricField.placeholder',
{ defaultMessage: 'Select a metric 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 &&
metricFields.some((metricField) => metricField.name === field.value)
? [
{
value: field.value,
label: field.value,
'data-test-subj': `customMetricIndicatorFormMetricFieldSelectedValue`,
},
]
: []
}
options={createOptions(metricFields)}
/>
)}
/>
</EuiFlexItem>
<EuiFlexItem grow={0}>
<EuiButtonIcon
iconType="trash"
color="danger"
style={{ marginBottom: '0.2em' }}
onClick={handleDeleteMetric(index)}
disabled={disableDelete}
title={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.deleteLabel',
{ defaultMessage: 'Delete metric' }
)}
aria-label={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.deleteLabel',
{ defaultMessage: 'Delete metric' }
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
))}
<EuiFlexGroup>
<EuiFlexItem grow={0}>
<EuiSpacer size="xs" />
<EuiButtonEmpty
data-test-subj="customMetricIndicatorAddMetricButton"
color={'primary'}
size="xs"
iconType={'plusInCircleFilled'}
onClick={handleAddMetric}
isDisabled={disableAdd}
aria-label={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.addMetricAriaLabel',
{ defaultMessage: 'Add metric' }
)}
>
<FormattedMessage
id="xpack.observability.slo.sloEdit.sliType.customMetric.addMetricLabel"
defaultMessage="Add metric"
/>
</EuiButtonEmpty>
<EuiSpacer size="m" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<Controller
name={`indicator.params.${type}.equation`}
shouldUnregister
defaultValue=""
rules={{
required: true,
validate: { validateEquation },
}}
control={control}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiFormRow
fullWidth
label={
<span>
{equationLabel} {equationTooltip}
</span>
}
helpText={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.equationHelpText',
{
defaultMessage:
'Supports basic math equations, valid charaters are: A-Z, +, -, /, *, (, ), ?, !, &, :, |, >, <, =',
}
)}
isInvalid={fieldState.invalid}
error={[
i18n.translate(
'xpack.observability.slo.sloEdit.sliType.customMetric.equation.invalidCharacters',
{
defaultMessage:
'The equation field only supports the following characters: A-Z, +, -, /, *, (, ), ?, !, &, :, |, >, <, =',
}
),
]}
>
<EuiFieldText
{...field}
isInvalid={fieldState.invalid}
fullWidth
data-test-subj="o11yCustomMetricEquation"
/>
</EuiFormRow>
)}
/>
</EuiFlexItem>
</>
);
}

View file

@ -202,7 +202,7 @@ export function SloEditForm({ slo }: Props) {
title: i18n.translate('xpack.observability.slo.sloEdit.definition.title', {
defaultMessage: 'Define SLI',
}),
children: <SloEditFormIndicatorSection />,
children: <SloEditFormIndicatorSection isEditMode={isEditMode} />,
status: isIndicatorSectionValid ? 'complete' : 'incomplete',
},
{

View file

@ -8,17 +8,46 @@
import { EuiFormRow, EuiPanel, EuiSelect, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { CreateSLOInput } from '@kbn/slo-schema';
import React from 'react';
import React, { useEffect } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { SLI_OPTIONS } from '../constants';
import { ApmAvailabilityIndicatorTypeForm } from './apm_availability/apm_availability_indicator_type_form';
import { ApmLatencyIndicatorTypeForm } from './apm_latency/apm_latency_indicator_type_form';
import { CustomKqlIndicatorTypeForm } from './custom_kql/custom_kql_indicator_type_form';
import {
CustomMetricIndicatorTypeForm,
NEW_CUSTOM_METRIC,
} from './custom_metric/custom_metric_type_form';
import { maxWidth } from './slo_edit_form';
export function SloEditFormIndicatorSection() {
const { control, watch } = useFormContext<CreateSLOInput>();
interface SloEditFormIndicatorSectionProps {
isEditMode: boolean;
}
export function SloEditFormIndicatorSection({ isEditMode }: SloEditFormIndicatorSectionProps) {
const { control, watch, setValue } = useFormContext<CreateSLOInput>();
const indicator = watch('indicator.type');
useEffect(() => {
if (!isEditMode) {
if (indicator === 'sli.metric.custom') {
setValue('indicator.params.index', '');
setValue('indicator.params.timestampField', '');
setValue('indicator.params.good.equation', 'A');
setValue('indicator.params.good.metrics', [NEW_CUSTOM_METRIC]);
setValue('indicator.params.total.equation', 'A');
setValue('indicator.params.total.metrics', [NEW_CUSTOM_METRIC]);
}
if (indicator === 'sli.kql.custom') {
setValue('indicator.params.index', '');
setValue('indicator.params.timestampField', '');
setValue('indicator.params.good', '');
setValue('indicator.params.total', '');
}
}
}, [indicator, setValue, isEditMode]);
const getIndicatorTypeForm = () => {
switch (watch('indicator.type')) {
@ -28,6 +57,8 @@ export function SloEditFormIndicatorSection() {
return <ApmLatencyIndicatorTypeForm />;
case 'sli.apm.transactionErrorRate':
return <ApmAvailabilityIndicatorTypeForm />;
case 'sli.metric.custom':
return <CustomMetricIndicatorTypeForm />;
default:
return null;
}
@ -41,28 +72,30 @@ export function SloEditFormIndicatorSection() {
style={{ maxWidth }}
data-test-subj="sloEditFormIndicatorSection"
>
<EuiFormRow
label={i18n.translate('xpack.observability.slo.sloEdit.definition.sliType', {
defaultMessage: 'Choose the SLI type',
})}
>
<Controller
name="indicator.type"
control={control}
rules={{ required: true }}
render={({ field: { ref, ...field } }) => (
<EuiSelect
{...field}
required
data-test-subj="sloFormIndicatorTypeSelect"
options={SLI_OPTIONS}
{!isEditMode && (
<>
<EuiFormRow
label={i18n.translate('xpack.observability.slo.sloEdit.definition.sliType', {
defaultMessage: 'Choose the SLI type',
})}
>
<Controller
name="indicator.type"
control={control}
rules={{ required: true }}
render={({ field: { ref, ...field } }) => (
<EuiSelect
{...field}
required
data-test-subj="sloFormIndicatorTypeSelect"
options={SLI_OPTIONS}
/>
)}
/>
)}
/>
</EuiFormRow>
<EuiSpacer size="xxl" />
</EuiFormRow>
<EuiSpacer size="xxl" />
</>
)}
{getIndicatorTypeForm()}
</EuiPanel>
);

View file

@ -13,6 +13,7 @@ import {
INDICATOR_APM_AVAILABILITY,
INDICATOR_APM_LATENCY,
INDICATOR_CUSTOM_KQL,
INDICATOR_CUSTOM_METRIC,
} from '../../utils/slo/labels';
export const SLI_OPTIONS: Array<{
@ -23,6 +24,10 @@ export const SLI_OPTIONS: Array<{
value: 'sli.kql.custom',
text: INDICATOR_CUSTOM_KQL,
},
{
value: 'sli.metric.custom',
text: INDICATOR_CUSTOM_METRIC,
},
{
value: 'sli.apm.transactionDuration',
text: INDICATOR_APM_LATENCY,
@ -76,3 +81,28 @@ export const SLO_EDIT_FORM_DEFAULT_VALUES: CreateSLOInput = {
target: 99,
},
};
export const SLO_EDIT_FORM_DEFAULT_VALUES_CUSTOM_METRIC: CreateSLOInput = {
name: '',
description: '',
indicator: {
type: 'sli.metric.custom',
params: {
index: '',
filter: '',
good: { metrics: [{ name: 'A', aggregation: 'sum', field: '' }], equation: 'A' },
total: { metrics: [{ name: 'A', aggregation: 'sum', field: '' }], equation: 'A' },
timestampField: '',
},
},
timeWindow: {
duration:
TIMEWINDOW_OPTIONS[TIMEWINDOW_OPTIONS.findIndex((option) => option.value === '30d')].value,
isRolling: true,
},
tags: [],
budgetingMethod: BUDGETING_METHOD_OPTIONS[0].value,
objective: {
target: 99,
},
};

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import { CreateSLOInput } from '@kbn/slo-schema';
import { CreateSLOInput, MetricCustomIndicatorSchema } from '@kbn/slo-schema';
import { FormState, UseFormGetFieldState, UseFormGetValues, UseFormWatch } from 'react-hook-form';
import { isObject } from 'lodash';
interface Props {
getFieldState: UseFormGetFieldState<CreateSLOInput>;
@ -19,6 +20,41 @@ export function useSectionFormValidation({ getFieldState, getValues, formState,
let isIndicatorSectionValid: boolean = false;
switch (watch('indicator.type')) {
case 'sli.metric.custom':
const isGoodParamsValid = () => {
const data = getValues(
'indicator.params.good'
) as MetricCustomIndicatorSchema['params']['good'];
const isEquationValid = !getFieldState('indicator.params.good.equation').invalid;
const areMetricsValid =
isObject(data) && (data.metrics ?? []).every((metric) => Boolean(metric.field));
return isEquationValid && areMetricsValid;
};
const isTotalParamsValid = () => {
const data = getValues(
'indicator.params.total'
) as MetricCustomIndicatorSchema['params']['total'];
const isEquationValid = !getFieldState('indicator.params.total.equation').invalid;
const areMetricsValid =
isObject(data) && (data.metrics ?? []).every((metric) => Boolean(metric.field));
return isEquationValid && areMetricsValid;
};
isIndicatorSectionValid =
(
[
'indicator.params.index',
'indicator.params.filter',
'indicator.params.timestampField',
] as const
).every((field) => !getFieldState(field).invalid) &&
(['indicator.params.index', 'indicator.params.timestampField'] as const).every(
(field) => !!getValues(field)
) &&
isGoodParamsValid() &&
isTotalParamsValid();
break;
case 'sli.kql.custom':
isIndicatorSectionValid =
(

View file

@ -451,7 +451,6 @@ describe('SLO Edit Page', () => {
expect(screen.queryByTestId('sloEditFormObjectiveSection')).toBeTruthy();
expect(screen.queryByTestId('sloEditFormDescriptionSection')).toBeTruthy();
expect(screen.queryByTestId('sloFormIndicatorTypeSelect')).toHaveValue(slo.indicator.type);
expect(screen.queryByTestId('indexSelectionSelectedValue')).toHaveTextContent(
slo.indicator.params.index!
);
@ -563,7 +562,6 @@ describe('SLO Edit Page', () => {
expect(screen.queryByTestId('sloEditFormObjectiveSection')).toBeTruthy();
expect(screen.queryByTestId('sloEditFormDescriptionSection')).toBeTruthy();
expect(screen.queryByTestId('sloFormIndicatorTypeSelect')).toHaveValue(slo.indicator.type);
expect(screen.queryByTestId('indexSelectionSelectedValue')).toHaveTextContent(
slo.indicator.params.index!
);

View file

@ -23,6 +23,7 @@ import {
INDICATOR_APM_AVAILABILITY,
INDICATOR_APM_LATENCY,
INDICATOR_CUSTOM_KQL,
INDICATOR_CUSTOM_METRIC,
} from '../../../utils/slo/labels';
export interface SloListSearchFilterSortBarProps {
@ -36,7 +37,8 @@ export type SortType = 'creationTime' | 'indicatorType';
export type FilterType =
| 'sli.apm.transactionDuration'
| 'sli.apm.transactionErrorRate'
| 'sli.kql.custom';
| 'sli.kql.custom'
| 'sli.metric.custom';
export type Item<T> = EuiSelectableOption & {
label: string;
@ -73,6 +75,10 @@ const INDICATOR_TYPE_OPTIONS: Array<Item<FilterType>> = [
label: INDICATOR_CUSTOM_KQL,
type: 'sli.kql.custom',
},
{
label: INDICATOR_CUSTOM_METRIC,
type: 'sli.metric.custom',
},
];
export function SloListSearchFilterSortBar({

View file

@ -14,6 +14,13 @@ export const INDICATOR_CUSTOM_KQL = i18n.translate('xpack.observability.slo.indi
defaultMessage: 'Custom KQL',
});
export const INDICATOR_CUSTOM_METRIC = i18n.translate(
'xpack.observability.slo.indicators.customMetric',
{
defaultMessage: 'Custom Metric',
}
);
export const INDICATOR_APM_LATENCY = i18n.translate(
'xpack.observability.slo.indicators.apmLatency',
{ defaultMessage: 'APM latency' }
@ -36,6 +43,10 @@ export function toIndicatorTypeLabel(
case 'sli.apm.transactionErrorRate':
return INDICATOR_APM_AVAILABILITY;
case 'sli.metric.custom':
return INDICATOR_CUSTOM_METRIC;
default:
assertNever(indicatorType);
}

View file

@ -13,11 +13,13 @@ import {
indicatorSchema,
indicatorTypesSchema,
kqlCustomIndicatorSchema,
metricCustomIndicatorSchema,
} from '@kbn/slo-schema';
type APMTransactionErrorRateIndicator = t.TypeOf<typeof apmTransactionErrorRateIndicatorSchema>;
type APMTransactionDurationIndicator = t.TypeOf<typeof apmTransactionDurationIndicatorSchema>;
type KQLCustomIndicator = t.TypeOf<typeof kqlCustomIndicatorSchema>;
type MetricCustomIndicator = t.TypeOf<typeof metricCustomIndicatorSchema>;
type Indicator = t.TypeOf<typeof indicatorSchema>;
type IndicatorTypes = t.TypeOf<typeof indicatorTypesSchema>;
type IndicatorData = t.TypeOf<typeof indicatorDataSchema>;
@ -28,5 +30,6 @@ export type {
APMTransactionErrorRateIndicator,
APMTransactionDurationIndicator,
KQLCustomIndicator,
MetricCustomIndicator,
IndicatorData,
};

View file

@ -31,6 +31,7 @@ import {
ApmTransactionDurationTransformGenerator,
ApmTransactionErrorRateTransformGenerator,
KQLCustomTransformGenerator,
MetricCustomTransformGenerator,
TransformGenerator,
} from '../../services/slo/transform_generators';
import { createObservabilityServerRoute } from '../create_observability_server_route';
@ -45,6 +46,7 @@ const transformGenerators: Record<IndicatorTypes, TransformGenerator> = {
'sli.apm.transactionDuration': new ApmTransactionDurationTransformGenerator(),
'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(),
'sli.kql.custom': new KQLCustomTransformGenerator(),
'sli.metric.custom': new MetricCustomTransformGenerator(),
};
const isLicenseAtLeastPlatinum = async (context: ObservabilityRequestHandlerContext) => {

View file

@ -18,6 +18,7 @@ import {
DurationUnit,
Indicator,
KQLCustomIndicator,
MetricCustomIndicator,
SLO,
StoredSLO,
} from '../../../domain/models';
@ -68,6 +69,29 @@ export const createKQLCustomIndicator = (
},
});
export const createMetricCustomIndicator = (
params: Partial<MetricCustomIndicator['params']> = {}
): Indicator => ({
type: 'sli.metric.custom',
params: {
index: 'my-index*',
filter: 'labels.groupId: group-3',
good: {
metrics: [
{ name: 'A', aggregation: 'sum', field: 'total' },
{ name: 'B', aggregation: 'sum', field: 'processed' },
],
equation: 'A - B',
},
total: {
metrics: [{ name: 'A', aggregation: 'sum', field: 'total' }],
equation: 'A',
},
timestampField: 'log_timestamp',
...params,
},
});
const defaultSLO: Omit<SLO, 'id' | 'revision' | 'createdAt' | 'updatedAt'> = {
name: 'irrelevant',
description: 'irrelevant',

View file

@ -0,0 +1,283 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Metric Custom Transform Generator aggregates using the denominator equation 1`] = `
Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_total_A",
},
"script": Object {
"lang": "painless",
"source": "params.A / 100",
},
},
}
`;
exports[`Metric Custom Transform Generator aggregates using the numerator equation 1`] = `
Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_good_A",
},
"script": Object {
"lang": "painless",
"source": "params.A * 100",
},
},
}
`;
exports[`Metric Custom 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[`Metric Custom 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_A": Object {
"sum": Object {
"field": "total",
},
},
"_good_B": Object {
"sum": Object {
"field": "processed",
},
},
"_total_A": Object {
"sum": Object {
"field": "total",
},
},
"slo.denominator": Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_total_A",
},
"script": Object {
"lang": "painless",
"source": "params.A",
},
},
},
"slo.isGoodSlice": Object {
"bucket_script": Object {
"buckets_path": Object {
"goodEvents": "slo.numerator",
"totalEvents": "slo.denominator",
},
"script": "params.goodEvents / params.totalEvents >= 0.95 ? 1 : 0",
},
},
"slo.numerator": Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_good_A",
"B": "_good_B",
},
"script": Object {
"lang": "painless",
"source": "params.A - params.B",
},
},
},
},
"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[`Metric Custom 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_A": Object {
"sum": Object {
"field": "total",
},
},
"_good_B": Object {
"sum": Object {
"field": "processed",
},
},
"_total_A": Object {
"sum": Object {
"field": "total",
},
},
"slo.denominator": Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_total_A",
},
"script": Object {
"lang": "painless",
"source": "params.A",
},
},
},
"slo.numerator": Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_good_A",
"B": "_good_B",
},
"script": Object {
"lang": "painless",
"source": "params.A - params.B",
},
},
},
},
"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

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

View file

@ -0,0 +1,136 @@
/*
* 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 {
createMetricCustomIndicator,
createSLO,
createSLOWithTimeslicesBudgetingMethod,
} from '../fixtures/slo';
import { MetricCustomTransformGenerator } from './metric_custom';
const generator = new MetricCustomTransformGenerator();
describe('Metric Custom Transform Generator', () => {
describe('validation', () => {
it('throws when the good equation is invalid', () => {
const anSLO = createSLO({
indicator: createMetricCustomIndicator({
good: {
metrics: [{ name: 'A', aggregation: 'sum', field: 'good' }],
equation: 'Math.floor(A / z)',
},
}),
});
expect(() => generator.getTransformParams(anSLO)).toThrow(/Invalid equation/);
});
it('throws when the total equation is invalid', () => {
const anSLO = createSLO({
indicator: createMetricCustomIndicator({
total: {
metrics: [{ name: 'A', aggregation: 'sum', field: 'total' }],
equation: 'Math.foo(A)',
},
}),
});
expect(() => generator.getTransformParams(anSLO)).toThrow(/Invalid equation/);
});
it('throws when the query_filter is invalid', () => {
const anSLO = createSLO({
indicator: createMetricCustomIndicator({ 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: createMetricCustomIndicator() });
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: createMetricCustomIndicator(),
});
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: createMetricCustomIndicator({ 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: createMetricCustomIndicator({ 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: createMetricCustomIndicator({
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: createMetricCustomIndicator({
good: {
metrics: [{ name: 'A', aggregation: 'sum', field: 'good' }],
equation: 'A * 100',
},
}),
});
const transform = generator.getTransformParams(anSLO);
expect(transform.pivot!.aggregations!['slo.numerator']).toMatchSnapshot();
});
it('aggregates using the denominator equation', async () => {
const anSLO = createSLO({
indicator: createMetricCustomIndicator({
total: {
metrics: [{ name: 'A', aggregation: 'sum', field: 'total' }],
equation: 'A / 100',
},
}),
});
const transform = generator.getTransformParams(anSLO);
expect(transform.pivot!.aggregations!['slo.denominator']).toMatchSnapshot();
});
});

View file

@ -0,0 +1,129 @@
/*
* 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 { metricCustomIndicatorSchema, 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 { MetricCustomIndicator, SLO } from '../../../domain/models';
type MetricCustomMetricDef =
| MetricCustomIndicator['params']['good']
| MetricCustomIndicator['params']['total'];
export const INVALID_EQUATION_REGEX = /[^A-Z|+|\-|\s|\d+|\.|\(|\)|\/|\*|>|<|=|\?|\:|&|\!|\|]+/g;
export class MetricCustomTransformGenerator extends TransformGenerator {
public getTransformParams(slo: SLO): TransformPutTransformRequest {
if (!metricCustomIndicatorSchema.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: MetricCustomIndicator) {
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 buildMetricAggregations(type: 'good' | 'total', metricDef: MetricCustomMetricDef) {
return metricDef.metrics.reduce(
(acc, metric) => ({
...acc,
[`_${type}_${metric.name}`]: {
[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}` }),
{}
);
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}`);
}
if (indicator.params.total.equation.match(INVALID_EQUATION_REGEX)) {
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);
return {
...goodAggregations,
...totalAggregations,
'slo.numerator': this.buildMetricEquation('good', indicator.params.good),
'slo.denominator': this.buildMetricEquation('total', indicator.params.total),
...(timeslicesBudgetingMethodSchema.is(slo.budgetingMethod) && {
'slo.isGoodSlice': {
bucket_script: {
buckets_path: {
goodEvents: 'slo.numerator',
totalEvents: 'slo.denominator',
},
script: `params.goodEvents / params.totalEvents >= ${slo.objective.timesliceTarget} ? 1 : 0`,
},
},
}),
};
}
}