mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[SLO] Introduce Custom Metric SLI (#157421)
This commit is contained in:
parent
d6b29afa2b
commit
ab9d3191c0
30 changed files with 1478 additions and 37 deletions
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
|
@ -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'
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
|
@ -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));
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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 =
|
||||
(
|
||||
|
|
|
@ -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!
|
||||
);
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>,
|
||||
}
|
||||
`;
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue