[SLO] Add timeslice metric indicator (#168539)

## Summary

This PR adds the new Timeslice Metric indicator for SLOs. Due to the
nature of these statistical aggregations, this indicator requires the
budgeting method to be set to `timeslices`; we ignore the timeslice
threshold in favor of the threshold set in the metric definition.

Users can create SLOs based on the following aggregations:
- Average
- Min
- Max
- Sum
- Cardinality
- Percentile
- Document count
- Std. Deviation
- Last Value

Other notable feature include:

- The ability to define an equation which supports basic math and logic
- Users can define a threshold based on the equation for good slices vs
bad slices

<img width="800" alt="image"
src="05af1ced-40cd-4a05-9dfc-e83ea7bfb5ab">

### Counter Metric Example

<img width="800" alt="image"
src="05eb5a0f-3043-493d-8add-df0d3bf8de02">


CC: @lucasmoore

---------

Co-authored-by: Kevin Delemme <kdelemme@gmail.com>
This commit is contained in:
Chris Cowan 2023-10-17 15:58:58 -06:00 committed by GitHub
parent 2932b77eec
commit befbe10fd3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 3225 additions and 29 deletions

View file

@ -19,6 +19,7 @@ import {
indicatorTypesSchema,
kqlCustomIndicatorSchema,
metricCustomIndicatorSchema,
timesliceMetricIndicatorSchema,
objectiveSchema,
optionalSettingsSchema,
previewDataSchema,
@ -28,6 +29,9 @@ import {
tagsSchema,
timeWindowSchema,
timeWindowTypeSchema,
timesliceMetricBasicMetricWithField,
timesliceMetricDocCountMetric,
timesliceMetricPercentileMetric,
} from '../schema';
const createSLOParamsSchema = t.type({
@ -270,6 +274,10 @@ type Indicator = t.OutputOf<typeof indicatorSchema>;
type APMTransactionErrorRateIndicator = t.OutputOf<typeof apmTransactionErrorRateIndicatorSchema>;
type APMTransactionDurationIndicator = t.OutputOf<typeof apmTransactionDurationIndicatorSchema>;
type MetricCustomIndicator = t.OutputOf<typeof metricCustomIndicatorSchema>;
type TimesliceMetricIndicator = t.OutputOf<typeof timesliceMetricIndicatorSchema>;
type TimesliceMetricBasicMetricWithField = t.OutputOf<typeof timesliceMetricBasicMetricWithField>;
type TimesliceMetricDocCountMetric = t.OutputOf<typeof timesliceMetricDocCountMetric>;
type TimesclieMetricPercentileMetric = t.OutputOf<typeof timesliceMetricPercentileMetric>;
type HistogramIndicator = t.OutputOf<typeof histogramIndicatorSchema>;
type KQLCustomIndicator = t.OutputOf<typeof kqlCustomIndicatorSchema>;
@ -327,6 +335,10 @@ export type {
IndicatorType,
Indicator,
MetricCustomIndicator,
TimesliceMetricIndicator,
TimesliceMetricBasicMetricWithField,
TimesclieMetricPercentileMetric,
TimesliceMetricDocCountMetric,
HistogramIndicator,
KQLCustomIndicator,
TimeWindow,

View file

@ -59,6 +59,83 @@ const kqlCustomIndicatorSchema = t.type({
]),
});
const timesliceMetricComparatorMapping = {
GT: '>',
GTE: '>=',
LT: '<',
LTE: '<=',
};
const timesliceMetricComparator = t.keyof(timesliceMetricComparatorMapping);
const timesliceMetricBasicMetricWithField = t.intersection([
t.type({
name: t.string,
aggregation: t.keyof({
avg: true,
max: true,
min: true,
sum: true,
cardinality: true,
last_value: true,
std_deviation: true,
}),
field: t.string,
}),
t.partial({
filter: t.string,
}),
]);
const timesliceMetricDocCountMetric = t.intersection([
t.type({
name: t.string,
aggregation: t.literal('doc_count'),
}),
t.partial({
filter: t.string,
}),
]);
const timesliceMetricPercentileMetric = t.intersection([
t.type({
name: t.string,
aggregation: t.literal('percentile'),
field: t.string,
percentile: t.number,
}),
t.partial({
filter: t.string,
}),
]);
const timesliceMetricMetricDef = t.union([
timesliceMetricBasicMetricWithField,
timesliceMetricDocCountMetric,
timesliceMetricPercentileMetric,
]);
const timesliceMetricDef = t.type({
metrics: t.array(timesliceMetricMetricDef),
equation: t.string,
threshold: t.number,
comparator: timesliceMetricComparator,
});
const timesliceMetricIndicatorTypeSchema = t.literal('sli.metric.timeslice');
const timesliceMetricIndicatorSchema = t.type({
type: timesliceMetricIndicatorTypeSchema,
params: t.intersection([
t.type({
index: t.string,
metric: timesliceMetricDef,
timestampField: t.string,
}),
t.partial({
filter: t.string,
}),
]),
});
const metricCustomValidAggregations = t.keyof({
sum: true,
});
@ -149,6 +226,7 @@ const indicatorTypesSchema = t.union([
apmTransactionErrorRateIndicatorTypeSchema,
kqlCustomIndicatorTypeSchema,
metricCustomIndicatorTypeSchema,
timesliceMetricIndicatorTypeSchema,
histogramIndicatorTypeSchema,
]);
@ -176,6 +254,7 @@ const indicatorSchema = t.union([
apmTransactionErrorRateIndicatorSchema,
kqlCustomIndicatorSchema,
metricCustomIndicatorSchema,
timesliceMetricIndicatorSchema,
histogramIndicatorSchema,
]);
@ -186,8 +265,15 @@ export {
apmTransactionErrorRateIndicatorTypeSchema,
kqlCustomIndicatorSchema,
kqlCustomIndicatorTypeSchema,
metricCustomIndicatorTypeSchema,
metricCustomIndicatorSchema,
metricCustomIndicatorTypeSchema,
timesliceMetricComparatorMapping,
timesliceMetricIndicatorSchema,
timesliceMetricIndicatorTypeSchema,
timesliceMetricMetricDef,
timesliceMetricBasicMetricWithField,
timesliceMetricDocCountMetric,
timesliceMetricPercentileMetric,
histogramIndicatorTypeSchema,
histogramIndicatorSchema,
indicatorSchema,

View file

@ -1240,6 +1240,210 @@
}
}
},
"timeslice_metric_basic_metric_with_field": {
"title": "Timeslice Metric Basic Metric with Field",
"required": [
"name",
"aggregation",
"field"
],
"type": "object",
"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.",
"type": "string",
"example": "sum",
"enum": [
"sum",
"avg",
"min",
"max",
"std_deviation",
"last_value",
"cardinality"
]
},
"field": {
"description": "The field of the metric.",
"type": "string",
"example": "processor.processed"
},
"filter": {
"description": "The filter to apply to the metric.",
"type": "string",
"example": "processor.outcome: \"success\""
}
}
},
"timeslice_metric_percentile_metric": {
"title": "Timeslice Metric Percentile Metric",
"required": [
"name",
"aggregation",
"field",
"percentile"
],
"type": "object",
"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 \"percentile\"",
"type": "string",
"example": "percentile",
"enum": [
"percentile"
]
},
"field": {
"description": "The field of the metric.",
"type": "string",
"example": "processor.processed"
},
"percentile": {
"description": "The percentile value.",
"type": "number",
"example": 95
},
"filter": {
"description": "The filter to apply to the metric.",
"type": "string",
"example": "processor.outcome: \"success\""
}
}
},
"timeslice_metric_doc_count_metric": {
"title": "Timeslice Metric Doc Count Metric",
"required": [
"name",
"aggregation"
],
"type": "object",
"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 \"doc_count\"",
"type": "string",
"example": "doc_count",
"enum": [
"doc_count"
]
},
"filter": {
"description": "The filter to apply to the metric.",
"type": "string",
"example": "processor.outcome: \"success\""
}
}
},
"indicator_properties_timeslice_metric": {
"title": "Timeslice metric",
"required": [
"type",
"params"
],
"description": "Defines properties for a timeslice metric indicator type",
"type": "object",
"properties": {
"params": {
"description": "An object containing the indicator parameters.",
"type": "object",
"nullable": false,
"required": [
"index",
"timestampField",
"metric"
],
"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.\n",
"type": "string",
"example": "timestamp"
},
"metric": {
"description": "An object defining the metrics, equation, and threshold to determine if it's a good slice or not\n",
"type": "object",
"required": [
"metrics",
"equation",
"comparator",
"threshold"
],
"properties": {
"metrics": {
"description": "List of metrics with their name, aggregation type, and field.",
"type": "array",
"items": {
"anyOf": [
{
"$ref": "#/components/schemas/timeslice_metric_basic_metric_with_field"
},
{
"$ref": "#/components/schemas/timeslice_metric_percentile_metric"
},
{
"$ref": "#/components/schemas/timeslice_metric_doc_count_metric"
}
]
}
},
"equation": {
"description": "The equation to calculate the metric.",
"type": "string",
"example": "A"
},
"comparator": {
"description": "The comparator to use to compare the equation to the threshold.",
"type": "string",
"example": "GT",
"enum": [
"GT",
"GTE",
"LT",
"LTE"
]
},
"threshold": {
"description": "The threshold used to determine if the metric is a good slice or not.",
"type": "number",
"example": 100
}
}
}
}
},
"type": {
"description": "The type of indicator.",
"type": "string",
"example": "sli.metric.timeslice"
}
}
},
"time_window": {
"title": "Time window",
"required": [
@ -1427,7 +1631,8 @@
"sli.kql.custom": "#/components/schemas/indicator_properties_custom_kql",
"sli.apm.transactionDuration": "#/components/schemas/indicator_properties_apm_latency",
"sli.metric.custom": "#/components/schemas/indicator_properties_custom_metric",
"sli.histogram.custom": "#/components/schemas/indicator_properties_histogram"
"sli.histogram.custom": "#/components/schemas/indicator_properties_histogram",
"sli.metric.timeslice": "#/components/schemas/indicator_properties_timeslice_metric"
}
},
"oneOf": [
@ -1445,6 +1650,9 @@
},
{
"$ref": "#/components/schemas/indicator_properties_histogram"
},
{
"$ref": "#/components/schemas/indicator_properties_timeslice_metric"
}
]
},
@ -1661,6 +1869,9 @@
},
{
"$ref": "#/components/schemas/indicator_properties_histogram"
},
{
"$ref": "#/components/schemas/indicator_properties_timeslice_metric"
}
]
},
@ -1755,6 +1966,9 @@
},
{
"$ref": "#/components/schemas/indicator_properties_histogram"
},
{
"$ref": "#/components/schemas/indicator_properties_timeslice_metric"
}
]
},

View file

@ -837,6 +837,162 @@ components:
description: The type of indicator.
type: string
example: sli.histogram.custom
timeslice_metric_basic_metric_with_field:
title: Timeslice Metric Basic Metric with Field
required:
- name
- aggregation
- field
type: object
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.
type: string
example: sum
enum:
- sum
- avg
- min
- max
- std_deviation
- last_value
- cardinality
field:
description: The field of the metric.
type: string
example: processor.processed
filter:
description: The filter to apply to the metric.
type: string
example: 'processor.outcome: "success"'
timeslice_metric_percentile_metric:
title: Timeslice Metric Percentile Metric
required:
- name
- aggregation
- field
- percentile
type: object
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 "percentile"
type: string
example: percentile
enum:
- percentile
field:
description: The field of the metric.
type: string
example: processor.processed
percentile:
description: The percentile value.
type: number
example: 95
filter:
description: The filter to apply to the metric.
type: string
example: 'processor.outcome: "success"'
timeslice_metric_doc_count_metric:
title: Timeslice Metric Doc Count Metric
required:
- name
- aggregation
type: object
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 "doc_count"
type: string
example: doc_count
enum:
- doc_count
filter:
description: The filter to apply to the metric.
type: string
example: 'processor.outcome: "success"'
indicator_properties_timeslice_metric:
title: Timeslice metric
required:
- type
- params
description: Defines properties for a timeslice metric indicator type
type: object
properties:
params:
description: An object containing the indicator parameters.
type: object
nullable: false
required:
- index
- timestampField
- metric
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
metric:
description: |
An object defining the metrics, equation, and threshold to determine if it's a good slice or not
type: object
required:
- metrics
- equation
- comparator
- threshold
properties:
metrics:
description: List of metrics with their name, aggregation type, and field.
type: array
items:
anyOf:
- $ref: '#/components/schemas/timeslice_metric_basic_metric_with_field'
- $ref: '#/components/schemas/timeslice_metric_percentile_metric'
- $ref: '#/components/schemas/timeslice_metric_doc_count_metric'
equation:
description: The equation to calculate the metric.
type: string
example: A
comparator:
description: The comparator to use to compare the equation to the threshold.
type: string
example: GT
enum:
- GT
- GTE
- LT
- LTE
threshold:
description: The threshold used to determine if the metric is a good slice or not.
type: number
example: 100
type:
description: The type of indicator.
type: string
example: sli.metric.timeslice
time_window:
title: Time window
required:
@ -988,12 +1144,14 @@ components:
sli.apm.transactionDuration: '#/components/schemas/indicator_properties_apm_latency'
sli.metric.custom: '#/components/schemas/indicator_properties_custom_metric'
sli.histogram.custom: '#/components/schemas/indicator_properties_histogram'
sli.metric.timeslice: '#/components/schemas/indicator_properties_timeslice_metric'
oneOf:
- $ref: '#/components/schemas/indicator_properties_custom_kql'
- $ref: '#/components/schemas/indicator_properties_apm_availability'
- $ref: '#/components/schemas/indicator_properties_apm_latency'
- $ref: '#/components/schemas/indicator_properties_custom_metric'
- $ref: '#/components/schemas/indicator_properties_histogram'
- $ref: '#/components/schemas/indicator_properties_timeslice_metric'
timeWindow:
$ref: '#/components/schemas/time_window'
budgetingMethod:
@ -1150,6 +1308,7 @@ components:
- $ref: '#/components/schemas/indicator_properties_apm_latency'
- $ref: '#/components/schemas/indicator_properties_custom_metric'
- $ref: '#/components/schemas/indicator_properties_histogram'
- $ref: '#/components/schemas/indicator_properties_timeslice_metric'
timeWindow:
$ref: '#/components/schemas/time_window'
budgetingMethod:
@ -1212,6 +1371,7 @@ components:
- $ref: '#/components/schemas/indicator_properties_apm_latency'
- $ref: '#/components/schemas/indicator_properties_custom_metric'
- $ref: '#/components/schemas/indicator_properties_histogram'
- $ref: '#/components/schemas/indicator_properties_timeslice_metric'
timeWindow:
$ref: '#/components/schemas/time_window'
budgetingMethod:

View file

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

View file

@ -0,0 +1,64 @@
title: Timeslice metric
required:
- type
- params
description: Defines properties for a timeslice metric indicator type
type: object
properties:
params:
description: An object containing the indicator parameters.
type: object
nullable: false
required:
- index
- timestampField
- metric
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
metric:
description: >
An object defining the metrics, equation, and threshold to determine if it's a good slice or not
type: object
required:
- metrics
- equation
- comparator
- threshold
properties:
metrics:
description: List of metrics with their name, aggregation type, and field.
type: array
items:
anyOf:
- $ref: './timeslice_metric_basic_metric_with_field.yaml'
- $ref: './timeslice_metric_percentile_metric.yaml'
- $ref: './timeslice_metric_doc_count_metric.yaml'
equation:
description: The equation to calculate the metric.
type: string
example: A
comparator:
description: The comparator to use to compare the equation to the threshold.
type: string
example: GT
enum: [GT, GTE, LT, LTE]
threshold:
description: The threshold used to determine if the metric is a good slice or not.
type: number
example: 100
type:
description: The type of indicator.
type: string
example: sli.metric.timeslice

View file

@ -37,14 +37,16 @@ properties:
sli.apm.transactionErrorRate: './indicator_properties_apm_availability.yaml'
sli.kql.custom: './indicator_properties_custom_kql.yaml'
sli.apm.transactionDuration: './indicator_properties_apm_latency.yaml'
sli.metric.custom: 'indicator_properties_custom_metric.yaml'
sli.histogram.custom: 'indicator_properties_histogram.yaml'
sli.metric.custom: './indicator_properties_custom_metric.yaml'
sli.histogram.custom: './indicator_properties_histogram.yaml'
sli.metric.timeslice: './indicator_properties_timeslice_metric.yaml'
oneOf:
- $ref: "indicator_properties_custom_kql.yaml"
- $ref: "indicator_properties_apm_availability.yaml"
- $ref: "indicator_properties_apm_latency.yaml"
- $ref: "indicator_properties_custom_metric.yaml"
- $ref: "indicator_properties_histogram.yaml"
- $ref: "indicator_properties_timeslice_metric.yaml"
timeWindow:
$ref: "time_window.yaml"
budgetingMethod:

View file

@ -0,0 +1,25 @@
title: Timeslice Metric Basic Metric with Field
required:
- name
- aggregation
- field
type: object
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.
type: string
example: sum
enum: [sum, avg, min, max, std_deviation, last_value, cardinality]
field:
description: The field of the metric.
type: string
example: processor.processed
filter:
description: The filter to apply to the metric.
type: string
example: 'processor.outcome: "success"'

View file

@ -0,0 +1,21 @@
title: Timeslice Metric Doc Count Metric
required:
- name
- aggregation
type: object
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 "doc_count"
type: string
example: doc_count
enum: [doc_count]
filter:
description: The filter to apply to the metric.
type: string
example: 'processor.outcome: "success"'

View file

@ -0,0 +1,30 @@
title: Timeslice Metric Percentile Metric
required:
- name
- aggregation
- field
- percentile
type: object
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 "percentile"
type: string
example: percentile
enum: [percentile]
field:
description: The field of the metric.
type: string
example: processor.processed
percentile:
description: The percentile value.
type: number
example: 95
filter:
description: The filter to apply to the metric.
type: string
example: 'processor.outcome: "success"'

View file

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

View file

@ -94,16 +94,26 @@ export function Overview({ slo }: Props) {
) : (
<EuiText size="s">
{BUDGETING_METHOD_TIMESLICES} (
{i18n.translate(
'xpack.observability.slo.sloDetails.overview.timeslicesBudgetingMethodDetails',
{
defaultMessage: '{duration} slices, {target} target',
values: {
duration: toDurationLabel(slo.objective.timesliceWindow!),
target: numeral(slo.objective.timesliceTarget!).format(percentFormat),
},
}
)}
{slo.indicator.type === 'sli.metric.timeslice'
? i18n.translate(
'xpack.observability.slo.sloDetails.overview.timeslicesBudgetingMethodDetailsForTimesliceMetric',
{
defaultMessage: '{duration} slices',
values: {
duration: toDurationLabel(slo.objective.timesliceWindow!),
},
}
)
: i18n.translate(
'xpack.observability.slo.sloDetails.overview.timeslicesBudgetingMethodDetails',
{
defaultMessage: '{duration} slices, {target} target',
values: {
duration: toDurationLabel(slo.objective.timesliceWindow!),
target: numeral(slo.objective.timesliceTarget!).format(percentFormat),
},
}
)}
)
</EuiText>
)

View file

@ -5,7 +5,18 @@
* 2.0.
*/
import { AreaSeries, Axis, Chart, Position, ScaleType, Settings, Tooltip } from '@elastic/charts';
import {
AnnotationDomainType,
AreaSeries,
Axis,
Chart,
LineAnnotation,
Position,
RectAnnotation,
ScaleType,
Settings,
Tooltip,
} from '@elastic/charts';
import {
EuiFlexGroup,
EuiFlexItem,
@ -22,12 +33,27 @@ import moment from 'moment';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { FormattedMessage } from '@kbn/i18n-react';
import { min, max } from 'lodash';
import { useKibana } from '../../../../utils/kibana_react';
import { useDebouncedGetPreviewData } from '../../hooks/use_preview';
import { useSectionFormValidation } from '../../hooks/use_section_form_validation';
import { CreateSLOForm } from '../../types';
export function DataPreviewChart() {
interface DataPreviewChartProps {
formatPattern?: string;
threshold?: number;
thresholdDirection?: 'above' | 'below';
thresholdColor?: string;
thresholdMessage?: string;
}
export function DataPreviewChart({
formatPattern,
threshold,
thresholdDirection,
thresholdColor,
thresholdMessage,
}: DataPreviewChartProps) {
const { watch, getFieldState, formState, getValues } = useFormContext<CreateSLOForm>();
const { charts, uiSettings } = useKibana().services;
const { isIndicatorSectionValid } = useSectionFormValidation({
@ -47,7 +73,22 @@ export function DataPreviewChart() {
const theme = charts.theme.useChartsTheme();
const baseTheme = charts.theme.useChartsBaseTheme();
const dateFormat = uiSettings.get('dateFormat');
const percentFormat = uiSettings.get('format:percent:defaultPattern');
const numberFormat =
formatPattern != null
? formatPattern
: (uiSettings.get('format:percent:defaultPattern') as string);
const values = (previewData || []).map((row) => row.sliValue);
const maxValue = max(values);
const minValue = min(values);
const domain = {
fit: true,
min:
threshold != null && minValue != null && threshold < minValue ? threshold : minValue || NaN,
max:
threshold != null && maxValue != null && threshold > maxValue ? threshold : maxValue || NaN,
};
const title = (
<>
<EuiTitle size="xs">
@ -85,6 +126,39 @@ export function DataPreviewChart() {
);
}
const annotation = threshold != null && (
<>
<LineAnnotation
id="thresholdAnnotation"
domainType={AnnotationDomainType.YDomain}
dataValues={[{ dataValue: threshold }]}
style={{
line: {
strokeWidth: 2,
stroke: thresholdColor || '#000',
opacity: 1,
},
}}
/>
<RectAnnotation
dataValues={[
{
coordinates:
thresholdDirection === 'above'
? {
y0: threshold,
y1: maxValue,
}
: { y0: minValue, y1: threshold },
details: thresholdMessage,
},
]}
id="thresholdShade"
style={{ fill: thresholdColor || '#000', opacity: 0.1 }}
/>
</>
);
return (
<EuiFlexItem>
{title}
@ -127,6 +201,8 @@ export function DataPreviewChart() {
locale={i18n.getLocale()}
/>
{annotation}
<Axis
id="y-axis"
title={i18n.translate('xpack.observability.slo.sloEdit.dataPreviewChart.yTitle', {
@ -134,12 +210,8 @@ export function DataPreviewChart() {
})}
ticks={5}
position={Position.Left}
tickFormat={(d) => numeral(d).format(percentFormat)}
domain={{
fit: true,
min: NaN,
max: NaN,
}}
tickFormat={(d) => numeral(d).format(numberFormat)}
domain={domain}
/>
<Axis

View file

@ -101,8 +101,8 @@ export function MetricIndicator({ type, metricFields, isLoadingIndex }: MetricIn
const equation = watch(`indicator.params.${type}.equation`);
const indexPattern = watch('indicator.params.index');
const disableAdd = fields?.length === MAX_VARIABLES;
const disableDelete = fields?.length === 1;
const disableAdd = fields?.length === MAX_VARIABLES || !indexPattern;
const disableDelete = fields?.length === 1 || !indexPattern;
const setDefaultEquationIfUnchanged = (previousNames: string[], nextNames: string[]) => {
const defaultEquation = createEquationFromMetric(previousNames);

View file

@ -18,6 +18,7 @@ import { CustomKqlIndicatorTypeForm } from './custom_kql/custom_kql_indicator_ty
import { CustomMetricIndicatorTypeForm } from './custom_metric/custom_metric_type_form';
import { HistogramIndicatorTypeForm } from './histogram/histogram_indicator_type_form';
import { maxWidth } from './slo_edit_form';
import { TimesliceMetricIndicatorTypeForm } from './timeslice_metric/timeslice_metric_indicator';
interface SloEditFormIndicatorSectionProps {
isEditMode: boolean;
@ -39,6 +40,8 @@ export function SloEditFormIndicatorSection({ isEditMode }: SloEditFormIndicator
return <CustomMetricIndicatorTypeForm />;
case 'sli.histogram.custom':
return <HistogramIndicatorTypeForm />;
case 'sli.metric.timeslice':
return <TimesliceMetricIndicatorTypeForm />;
default:
return null;
}

View file

@ -6,6 +6,7 @@
*/
import {
EuiCallOut,
EuiFieldNumber,
EuiFlexGrid,
EuiFlexItem,
@ -20,6 +21,7 @@ import { i18n } from '@kbn/i18n';
import { TimeWindow } from '@kbn/slo-schema';
import React, { useEffect, useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { FormattedMessage } from '@kbn/i18n-react';
import {
BUDGETING_METHOD_OPTIONS,
CALENDARALIGNED_TIMEWINDOW_OPTIONS,
@ -42,6 +44,7 @@ export function SloEditFormObjectiveSection() {
const timeWindowTypeSelect = useGeneratedHtmlId({ prefix: 'timeWindowTypeSelect' });
const timeWindowSelect = useGeneratedHtmlId({ prefix: 'timeWindowSelect' });
const timeWindowType = watch('timeWindow.type');
const indicator = watch('indicator.type');
const [timeWindowTypeState, setTimeWindowTypeState] = useState<TimeWindow | undefined>(
defaultValues?.timeWindow?.type
@ -169,6 +172,19 @@ export function SloEditFormObjectiveSection() {
</EuiFlexGrid>
<EuiSpacer size="l" />
{indicator === 'sli.metric.timeslice' && (
<EuiFlexItem>
<EuiCallOut color="warning">
<p>
<FormattedMessage
id="xpack.observability.slo.sloEdit.sliType.timesliceMetric.objectiveMessage"
defaultMessage="The timeslice metric requires the budgeting method to be set to 'Timeslices' due to the nature of the statistical aggregations. The 'timeslice target' is also ignored in favor of the 'threshold' set in the metric definition above. The 'timeslice window' will set the size of the window the aggregation is performed on."
/>
</p>
</EuiCallOut>
<EuiSpacer size="l" />
</EuiFlexItem>
)}
<EuiFlexGrid columns={3}>
<EuiFlexItem>
@ -198,6 +214,7 @@ export function SloEditFormObjectiveSection() {
render={({ field: { ref, ...field } }) => (
<EuiSelect
{...field}
disabled={indicator === 'sli.metric.timeslice'}
required
id={budgetingSelect}
data-test-subj="sloFormBudgetingMethodSelect"

View file

@ -12,7 +12,8 @@ import { Controller, useFormContext } from 'react-hook-form';
import { CreateSLOForm } from '../types';
export function SloEditFormObjectiveSectionTimeslices() {
const { control, getFieldState } = useFormContext<CreateSLOForm>();
const { control, getFieldState, watch } = useFormContext<CreateSLOForm>();
const indicator = watch('indicator.type');
return (
<>
@ -47,6 +48,7 @@ export function SloEditFormObjectiveSectionTimeslices() {
<EuiFieldNumber
{...field}
required
disabled={indicator === 'sli.metric.timeslice'}
isInvalid={fieldState.invalid}
value={field.value}
data-test-subj="sloFormObjectiveTimesliceTargetInput"

View file

@ -0,0 +1,303 @@
/*
* 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 {
EuiButtonEmpty,
EuiButtonIcon,
EuiFieldNumber,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIconTip,
EuiSelect,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { first, range, xor } from 'lodash';
import React from 'react';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { Field } from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { COMPARATOR_OPTIONS } from '../../constants';
import { CreateSLOForm } from '../../types';
import { MetricInput } from './metric_input';
interface MetricIndicatorProps {
indexFields: Field[];
isLoadingIndex: boolean;
}
export const NEW_TIMESLICE_METRIC = { name: 'A', aggregation: 'avg' 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 createEquationFromMetric(names: string[]) {
return names.join(' + ');
}
const equationLabel = i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.equationLabel',
{ defaultMessage: 'Equation' }
);
const equationTooltip = (
<EuiIconTip
content={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.totalEquation.tooltip',
{
defaultMessage: 'This supports basic math (A + B / C) and boolean logic (A < B ? A : B).',
}
)}
position="top"
/>
);
const thresholdLabel = i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.thresholdLabel',
{
defaultMessage: 'Threshold',
}
);
const thresholdTooltip = (
<EuiIconTip
content={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.threshold.tooltip',
{
defaultMessage:
'This value combined with the comparator will determine if the slice is "good" or "bad".',
}
)}
position="top"
/>
);
export function MetricIndicator({ indexFields, isLoadingIndex }: MetricIndicatorProps) {
const { control, watch, setValue, register, getFieldState } = useFormContext<CreateSLOForm>();
const { fields, append, remove } = useFieldArray({
control,
name: `indicator.params.metric.metrics`,
});
const equation = watch(`indicator.params.metric.equation`);
const indexPattern = watch('indicator.params.index');
const disableAdd = fields?.length === MAX_VARIABLES || !indexPattern;
const disableDelete = fields?.length === 1 || !indexPattern;
const setDefaultEquationIfUnchanged = (previousNames: string[], nextNames: string[]) => {
const defaultEquation = createEquationFromMetric(previousNames);
if (defaultEquation === equation) {
setValue(`indicator.params.metric.equation`, createEquationFromMetric(nextNames));
}
};
const handleDeleteMetric = (index: number) => () => {
const currentVars = fields.map((m) => m.name) ?? ['A'];
const deletedVar = currentVars[index];
setDefaultEquationIfUnchanged(currentVars, xor(currentVars, [deletedVar]));
remove(index);
};
const handleAddMetric = () => {
const currentVars = fields.map((m) => m.name) ?? ['A'];
const name = first(xor(VAR_NAMES, currentVars))!;
setDefaultEquationIfUnchanged(currentVars, [...currentVars, name]);
append({ ...NEW_TIMESLICE_METRIC, name });
};
return (
<>
<EuiFlexItem>
{fields?.map((metric, index) => (
<React.Fragment key={metric.id}>
<EuiFlexGroup alignItems="center" gutterSize="xs">
<input hidden {...register(`indicator.params.metric.metrics.${index}.name`)} />
<MetricInput
isLoadingIndex={isLoadingIndex}
metricIndex={index}
indexPattern={indexPattern}
indexFields={indexFields}
/>
<EuiFlexItem grow={0}>
<EuiButtonIcon
data-test-subj="o11yMetricIndicatorButton"
iconType="trash"
color="danger"
style={{ marginTop: '1.5em' }}
onClick={handleDeleteMetric(index)}
disabled={disableDelete}
title={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.deleteLabel',
{ defaultMessage: 'Delete metric' }
)}
aria-label={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.deleteLabel',
{ defaultMessage: 'Delete metric' }
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xs" />
</React.Fragment>
))}
<EuiFlexGroup>
<EuiFlexItem grow={0}>
<EuiSpacer size="xs" />
<EuiButtonEmpty
data-test-subj="timesliceMetricIndicatorAddMetricButton"
color={'primary'}
size="xs"
iconType={'plusInCircleFilled'}
onClick={handleAddMetric}
isDisabled={disableAdd}
aria-label={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.addMetricAriaLabel',
{ defaultMessage: 'Add metric' }
)}
>
<FormattedMessage
id="xpack.observability.slo.sloEdit.sliType.timesliceMetric.addMetricLabel"
defaultMessage="Add metric"
/>
</EuiButtonEmpty>
<EuiSpacer size="m" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem>
<Controller
name={`indicator.params.metric.equation`}
defaultValue=""
rules={{
required: true,
validate: { validateEquation },
}}
control={control}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiFormRow
fullWidth
label={
<span>
{equationLabel} {equationTooltip}
</span>
}
isInvalid={fieldState.invalid}
error={[
i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.equation.invalidCharacters',
{
defaultMessage:
'The equation field only supports the following characters: A-Z, +, -, /, *, (, ), ?, !, &, :, |, >, <, =',
}
),
]}
>
<EuiFieldText
{...field}
isInvalid={fieldState.invalid}
disabled={!indexPattern}
fullWidth
value={field.value}
data-test-subj="timesliceMetricEquation"
onChange={(event) => field.onChange(event.target.value)}
/>
</EuiFormRow>
)}
/>
</EuiFlexItem>
<EuiFlexItem grow={0}>
<Controller
name={`indicator.params.metric.comparator`}
rules={{
required: true,
}}
control={control}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiFormRow
fullWidth
isInvalid={fieldState.invalid}
label={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.comparatorLabel',
{
defaultMessage: 'Comparator',
}
)}
>
<EuiSelect
{...field}
data-test-subj="timesliceMetricComparatorSelection"
disabled={!indexPattern}
value={field.value}
options={COMPARATOR_OPTIONS}
onChange={(event) => field.onChange(event.target.value)}
/>
</EuiFormRow>
)}
/>
</EuiFlexItem>
<EuiFlexItem grow={0}>
<EuiFormRow
fullWidth
isInvalid={getFieldState('indicator.params.metric.threshold').invalid}
label={
<span>
{thresholdLabel} {thresholdTooltip}
</span>
}
>
<Controller
name={'indicator.params.metric.threshold'}
control={control}
rules={{
required: true,
}}
defaultValue={0}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiFieldNumber
required
isInvalid={fieldState.invalid}
data-test-subj="timesliceMetricThreshold"
value={field.value}
style={{ width: 80 }}
disabled={!indexPattern}
onChange={(event) => field.onChange(Number(event.target.value))}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem>
<EuiSpacer size="xs" />
<EuiText size="xs" color="subdued">
<p>
{i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.equationHelpText',
{
defaultMessage:
'Supports basic math equations, valid charaters are: A-Z, +, -, /, *, (, ), ?, !, &, :, |, >, <, =',
}
)}
</p>
</EuiText>
</EuiFlexItem>
</EuiFlexItem>
</>
);
}

View file

@ -0,0 +1,263 @@
/*
* 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,
EuiFieldNumber,
EuiFlexItem,
EuiFormRow,
EuiIconTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Controller, useFormContext } from 'react-hook-form';
import { createOptionsFromFields } from '../../helpers/create_options';
import { QueryBuilder } from '../common/query_builder';
import { CreateSLOForm } from '../../types';
import { AGGREGATION_OPTIONS, aggValueToLabel } from '../../helpers/aggregation_options';
import { Field } from '../../../../hooks/slo/use_fetch_index_pattern_fields';
const fieldLabel = i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.fieldLabel',
{ defaultMessage: 'Field' }
);
const aggregationLabel = i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.aggregationLabel',
{ defaultMessage: 'Aggregation' }
);
const filterLabel = i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.filterLabel',
{ defaultMessage: 'Filter' }
);
const fieldTooltip = (
<EuiIconTip
content={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.totalMetric.tooltip',
{
defaultMessage: 'This is the field used in the aggregation.',
}
)}
position="top"
/>
);
const NUMERIC_FIELD_TYPES = ['number', 'histogram'];
const CARDINALITY_FIELD_TYPES = ['number', 'string'];
interface MetricInputProps {
metricIndex: number;
indexPattern: string;
isLoadingIndex: boolean;
indexFields: Field[];
}
export function MetricInput({
metricIndex: index,
indexPattern,
isLoadingIndex,
indexFields,
}: MetricInputProps) {
const { control, watch } = useFormContext<CreateSLOForm>();
const metric = watch(`indicator.params.metric.metrics.${index}`);
const metricFields = indexFields.filter((field) =>
metric.aggregation === 'cardinality'
? CARDINALITY_FIELD_TYPES.includes(field.type)
: NUMERIC_FIELD_TYPES.includes(field.type)
);
return (
<>
<EuiFlexItem>
<Controller
name={`indicator.params.metric.metrics.${index}.aggregation`}
defaultValue="avg"
rules={{ required: true }}
control={control}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiFormRow
fullWidth
label={
<span>
{aggregationLabel} {metric.name}
</span>
}
isInvalid={fieldState.invalid}
>
<EuiComboBox
{...field}
async
fullWidth
isClearable={false}
singleSelection={{ asPlainText: true }}
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.aggregationField.placeholder',
{ defaultMessage: 'Select an aggregation' }
)}
aria-label={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.aggregationField.placeholder',
{ defaultMessage: 'Select an aggregation' }
)}
isInvalid={fieldState.invalid}
isDisabled={!indexPattern}
isLoading={!!indexPattern && isLoadingIndex}
onChange={(selected: EuiComboBoxOptionOption[]) => {
if (selected.length) {
return field.onChange(selected[0].value);
}
field.onChange('');
}}
selectedOptions={
!!indexPattern &&
!!field.value &&
AGGREGATION_OPTIONS.some((agg) => agg.value === agg.value)
? [
{
value: field.value,
label: aggValueToLabel(field.value),
},
]
: []
}
options={AGGREGATION_OPTIONS}
/>
</EuiFormRow>
)}
/>
</EuiFlexItem>
{metric.aggregation === 'percentile' && (
<EuiFlexItem grow={0}>
<Controller
name={`indicator.params.metric.metrics.${index}.percentile`}
defaultValue={95}
rules={{
required: true,
min: 0.001,
max: 99.999,
}}
shouldUnregister={true}
control={control}
render={({ field: { ref, onChange, ...field }, fieldState }) => (
<EuiFormRow
fullWidth
isInvalid={fieldState.invalid}
label={
<span>
{i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.percentileLabel',
{ defaultMessage: 'Percentile' }
)}{' '}
{metric.name}
</span>
}
>
<EuiFieldNumber
{...field}
style={{ width: 80 }}
data-test-subj="timesliceMetricPercentileNumber"
required
min={0.1}
max={99.999}
step={0.1}
value={field.value}
isInvalid={fieldState.invalid}
disabled={!indexPattern}
isLoading={!!indexPattern && isLoadingIndex}
onChange={(event) => onChange(Number(event.target.value))}
/>
</EuiFormRow>
)}
/>
</EuiFlexItem>
)}
{metric.aggregation !== 'doc_count' && (
<EuiFlexItem>
<Controller
name={`indicator.params.metric.metrics.${index}.field`}
defaultValue=""
rules={{ required: true }}
shouldUnregister={true}
control={control}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiFormRow
fullWidth
isInvalid={fieldState.invalid}
label={
<span>
{fieldLabel} {metric.name} {fieldTooltip}
</span>
}
>
<EuiComboBox
{...field}
async
fullWidth
singleSelection={{ asPlainText: true }}
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.metricField.placeholder',
{ defaultMessage: 'Select a metric field' }
)}
aria-label={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.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,
},
]
: []
}
options={createOptionsFromFields(metricFields)}
/>
</EuiFormRow>
)}
/>
</EuiFlexItem>
)}
<EuiFlexItem>
<QueryBuilder
dataTestSubj="timesliceMetricIndicatorFormMetricQueryInput"
indexPatternString={watch('indicator.params.index')}
label={`${filterLabel} ${metric.name}`}
name={`indicator.params.metric.metrics.${index}.filter`}
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.goodQuery.placeholder',
{ defaultMessage: 'KQL filter' }
)}
required={false}
tooltip={
<EuiIconTip
content={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.goodQuery.tooltip',
{
defaultMessage: 'This KQL query should return a subset of events.',
}
)}
position="top"
/>
}
/>
</EuiFlexItem>
</>
);
}

View file

@ -0,0 +1,163 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiIconTip,
EuiSpacer,
EuiTitle,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { useFetchIndexPatternFields } from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { CreateSLOForm } from '../../types';
import { DataPreviewChart } from '../common/data_preview_chart';
import { IndexFieldSelector } from '../common/index_field_selector';
import { QueryBuilder } from '../common/query_builder';
import { IndexSelection } from '../custom_common/index_selection';
import { MetricIndicator } from './metric_indicator';
import { useKibana } from '../../../../utils/kibana_react';
import { COMPARATOR_MAPPING } from '../../constants';
export { NEW_TIMESLICE_METRIC } from './metric_indicator';
export function TimesliceMetricIndicatorTypeForm() {
const { watch } = useFormContext<CreateSLOForm>();
const index = watch('indicator.params.index');
const { isLoading: isIndexFieldsLoading, data: indexFields = [] } =
useFetchIndexPatternFields(index);
const timestampFields = indexFields.filter((field) => field.type === 'date');
const partitionByFields = indexFields.filter((field) => field.aggregatable);
const { uiSettings } = useKibana().services;
const threshold = watch('indicator.params.metric.threshold');
const comparator = watch('indicator.params.metric.comparator');
const { euiTheme } = useEuiTheme();
return (
<>
<EuiTitle size="xs">
<h3>
<FormattedMessage
id="xpack.observability.slo.sloEdit.sliType.sourceTitle"
defaultMessage="Source"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup direction="row" gutterSize="l">
<EuiFlexItem>
<IndexSelection />
</EuiFlexItem>
<EuiFlexItem>
<IndexFieldSelector
indexFields={timestampFields}
name="indicator.params.timestampField"
label={i18n.translate('xpack.observability.slo.sloEdit.timestampField.label', {
defaultMessage: 'Timestamp field',
})}
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.timestampField.placeholder',
{ defaultMessage: 'Select a timestamp field' }
)}
isLoading={!!index && isIndexFieldsLoading}
isDisabled={!index}
isRequired
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem>
<QueryBuilder
dataTestSubj="timesliceMetricIndicatorFormQueryFilterInput"
indexPatternString={watch('indicator.params.index')}
label={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.queryFilter',
{
defaultMessage: 'Query filter',
}
)}
name="indicator.params.filter"
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.customFilter',
{ defaultMessage: 'Custom filter to apply on the index' }
)}
tooltip={
<EuiIconTip
content={i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.customFilter.tooltip',
{
defaultMessage:
'This KQL query can be used to filter the documents with some relevant criteria.',
}
)}
position="top"
/>
}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiHorizontalRule margin="none" />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xs">
<h3>
<FormattedMessage
id="xpack.observability.slo.sloEdit.sliType.timesliceMetric.metricTitle"
defaultMessage="Metric definition"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<MetricIndicator indexFields={indexFields} isLoadingIndex={isIndexFieldsLoading} />
</EuiFlexItem>
<EuiFlexItem>
<EuiHorizontalRule margin="none" />
</EuiFlexItem>
<IndexFieldSelector
indexFields={partitionByFields}
name="groupBy"
label={
<span>
{i18n.translate('xpack.observability.slo.sloEdit.groupBy.label', {
defaultMessage: 'Partition by',
})}{' '}
<EuiIconTip
content={i18n.translate('xpack.observability.slo.sloEdit.groupBy.tooltip', {
defaultMessage: 'Create individual SLOs for each value of the selected field.',
})}
position="top"
/>
</span>
}
placeholder={i18n.translate('xpack.observability.slo.sloEdit.groupBy.placeholder', {
defaultMessage: 'Select an optional field to partition by',
})}
isLoading={!!index && isIndexFieldsLoading}
isDisabled={!index}
/>
<DataPreviewChart
formatPattern={uiSettings.get('format:number:defaultPattern')}
threshold={threshold}
thresholdDirection={['GT', 'GTE'].includes(comparator) ? 'above' : 'below'}
thresholdColor={euiTheme.colors.warning}
thresholdMessage={`${COMPARATOR_MAPPING[comparator]} ${threshold}`}
/>
</EuiFlexGroup>
</>
);
}

View file

@ -15,6 +15,7 @@ import {
IndicatorType,
KQLCustomIndicator,
MetricCustomIndicator,
TimesliceMetricIndicator,
TimeWindow,
} from '@kbn/slo-schema';
import {
@ -25,6 +26,7 @@ import {
INDICATOR_CUSTOM_KQL,
INDICATOR_CUSTOM_METRIC,
INDICATOR_HISTOGRAM,
INDICATOR_TIMESLICE_METRIC,
} from '../../utils/slo/labels';
import { CreateSLOForm } from './types';
@ -40,6 +42,10 @@ export const SLI_OPTIONS: Array<{
value: 'sli.metric.custom',
text: INDICATOR_CUSTOM_METRIC,
},
{
value: 'sli.metric.timeslice',
text: INDICATOR_TIMESLICE_METRIC,
},
{
value: 'sli.histogram.custom',
text: INDICATOR_HISTOGRAM,
@ -125,6 +131,21 @@ export const CUSTOM_METRIC_DEFAULT_VALUES: MetricCustomIndicator = {
},
};
export const TIMESLICE_METRIC_DEFAULT_VALUES: TimesliceMetricIndicator = {
type: 'sli.metric.timeslice' as const,
params: {
index: '',
filter: '',
metric: {
metrics: [{ name: 'A', aggregation: 'avg' as const, field: '' }],
equation: 'A',
comparator: 'GT',
threshold: 0,
},
timestampField: '',
},
};
export const HISTOGRAM_DEFAULT_VALUES: HistogramIndicator = {
type: 'sli.histogram.custom' as const,
params: {
@ -198,3 +219,57 @@ export const SLO_EDIT_FORM_DEFAULT_VALUES_CUSTOM_METRIC: CreateSLOForm = {
},
groupBy: ALL_VALUE,
};
export const COMPARATOR_GT = i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.gtLabel',
{
defaultMessage: 'Greater than',
}
);
export const COMPARATOR_GTE = i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.gteLabel',
{
defaultMessage: 'Greater than or equal to',
}
);
export const COMPARATOR_LT = i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.ltLabel',
{
defaultMessage: 'Less than',
}
);
export const COMPARATOR_LTE = i18n.translate(
'xpack.observability.slo.sloEdit.sliType.timesliceMetric.lteLabel',
{
defaultMessage: 'Less than or equal to',
}
);
export const COMPARATOR_MAPPING = {
GT: COMPARATOR_GT,
GTE: COMPARATOR_GTE,
LT: COMPARATOR_LT,
LTE: COMPARATOR_LTE,
};
export const COMPARATOR_OPTIONS = [
{
text: COMPARATOR_GT,
value: 'GT' as const,
},
{
text: COMPARATOR_GTE,
value: 'GTE' as const,
},
{
text: COMPARATOR_LT,
value: 'LT' as const,
},
{
text: COMPARATOR_LTE,
value: 'LTE' as const,
},
];

View file

@ -0,0 +1,84 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const AGGREGATION_OPTIONS = [
{
value: 'avg',
label: i18n.translate('xpack.observability.slo.sloEdit.timesliceMetric.aggregation.average', {
defaultMessage: 'Average',
}),
},
{
value: 'max',
label: i18n.translate('xpack.observability.slo.sloEdit.timesliceMetric.aggregation.max', {
defaultMessage: 'Max',
}),
},
{
value: 'min',
label: i18n.translate('xpack.observability.slo.sloEdit.timesliceMetric.aggregation.min', {
defaultMessage: 'Min',
}),
},
{
value: 'sum',
label: i18n.translate('xpack.observability.slo.sloEdit.timesliceMetric.aggregation.sum', {
defaultMessage: 'Sum',
}),
},
{
value: 'cardinality',
label: i18n.translate(
'xpack.observability.slo.sloEdit.timesliceMetric.aggregation.cardinality',
{
defaultMessage: 'Cardinality',
}
),
},
{
value: 'last_value',
label: i18n.translate(
'xpack.observability.slo.sloEdit.timesliceMetric.aggregation.last_value',
{
defaultMessage: 'Last value',
}
),
},
{
value: 'std_deviation',
label: i18n.translate(
'xpack.observability.slo.sloEdit.timesliceMetric.aggregation.std_deviation',
{
defaultMessage: 'Std. Deviation',
}
),
},
{
value: 'doc_count',
label: i18n.translate('xpack.observability.slo.sloEdit.timesliceMetric.aggregation.doc_count', {
defaultMessage: 'Doc count',
}),
},
{
value: 'percentile',
label: i18n.translate(
'xpack.observability.slo.sloEdit.timesliceMetric.aggregation.percentile',
{
defaultMessage: 'Percentile',
}
),
},
];
export function aggValueToLabel(value: string) {
const aggregation = AGGREGATION_OPTIONS.find((agg) => agg.value === value);
if (aggregation) {
return aggregation.label;
}
return value;
}

View file

@ -15,6 +15,7 @@ import {
CUSTOM_KQL_DEFAULT_VALUES,
CUSTOM_METRIC_DEFAULT_VALUES,
HISTOGRAM_DEFAULT_VALUES,
TIMESLICE_METRIC_DEFAULT_VALUES,
} from '../constants';
import { CreateSLOForm } from '../types';
@ -132,6 +133,11 @@ function transformPartialIndicatorState(
type: 'sli.metric.custom' as const,
params: Object.assign({}, CUSTOM_METRIC_DEFAULT_VALUES.params, indicator.params ?? {}),
};
case 'sli.metric.timeslice':
return {
type: 'sli.metric.timeslice' as const,
params: Object.assign({}, TIMESLICE_METRIC_DEFAULT_VALUES.params, indicator.params ?? {}),
};
default:
assertNever(indicatorType);
}

View file

@ -5,7 +5,12 @@
* 2.0.
*/
import { MetricCustomIndicator } from '@kbn/slo-schema';
import {
MetricCustomIndicator,
timesliceMetricBasicMetricWithField,
TimesliceMetricIndicator,
timesliceMetricPercentileMetric,
} from '@kbn/slo-schema';
import { FormState, UseFormGetFieldState, UseFormGetValues, UseFormWatch } from 'react-hook-form';
import { isObject } from 'lodash';
import { CreateSLOForm } from '../types';
@ -54,6 +59,39 @@ export function useSectionFormValidation({ getFieldState, getValues, formState,
isGoodParamsValid() &&
isTotalParamsValid();
break;
case 'sli.metric.timeslice':
const isMetricParamsValid = () => {
const data = getValues(
'indicator.params.metric'
) as TimesliceMetricIndicator['params']['metric'];
const isEquationValid = !getFieldState('indicator.params.metric.equation').invalid;
const areMetricsValid =
isObject(data) &&
(data.metrics ?? []).every((metric) => {
if (timesliceMetricBasicMetricWithField.is(metric)) {
return Boolean(metric.field);
}
if (timesliceMetricPercentileMetric.is(metric)) {
return Boolean(metric.field) && Boolean(metric.percentile);
}
return true;
});
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)
) &&
isMetricParamsValid();
break;
case 'sli.histogram.custom':
const isRangeValid = (type: 'good' | 'total') => {
const aggregation = getValues(`indicator.params.${type}.aggregation`);

View file

@ -14,10 +14,12 @@ import { useFetchApmIndex } from '../../../hooks/slo/use_fetch_apm_indices';
import {
APM_AVAILABILITY_DEFAULT_VALUES,
APM_LATENCY_DEFAULT_VALUES,
BUDGETING_METHOD_OPTIONS,
CUSTOM_KQL_DEFAULT_VALUES,
CUSTOM_METRIC_DEFAULT_VALUES,
HISTOGRAM_DEFAULT_VALUES,
SLO_EDIT_FORM_DEFAULT_VALUES,
TIMESLICE_METRIC_DEFAULT_VALUES,
} from '../constants';
import { CreateSLOForm } from '../types';
@ -49,6 +51,22 @@ export function useUnregisterFields({ isEditMode }: { isEditMode: boolean }) {
}
);
break;
case 'sli.metric.timeslice':
reset(
Object.assign({}, SLO_EDIT_FORM_DEFAULT_VALUES, {
budgetingMethod: BUDGETING_METHOD_OPTIONS[1].value,
objective: {
target: 99,
timesliceTarget: 95,
timesliceWindow: 1,
},
indicator: TIMESLICE_METRIC_DEFAULT_VALUES,
}),
{
keepDefaultValues: true,
}
);
break;
case 'sli.kql.custom':
reset(
Object.assign({}, SLO_EDIT_FORM_DEFAULT_VALUES, {

View file

@ -21,6 +21,13 @@ export const INDICATOR_CUSTOM_METRIC = i18n.translate(
}
);
export const INDICATOR_TIMESLICE_METRIC = i18n.translate(
'xpack.observability.slo.indicators.timesliceMetric',
{
defaultMessage: 'Timeslice Metric',
}
);
export const INDICATOR_HISTOGRAM = i18n.translate('xpack.observability.slo.indicators.histogram', {
defaultMessage: 'Histogram Metric',
});
@ -54,6 +61,9 @@ export function toIndicatorTypeLabel(
case 'sli.histogram.custom':
return INDICATOR_HISTOGRAM;
case 'sli.metric.timeslice':
return INDICATOR_TIMESLICE_METRIC;
default:
assertNever(indicatorType as never);
}

View file

@ -49,6 +49,7 @@ import {
KQLCustomTransformGenerator,
MetricCustomTransformGenerator,
TransformGenerator,
TimesliceMetricTransformGenerator,
} from '../../services/slo/transform_generators';
import type { ObservabilityRequestHandlerContext } from '../../types';
import { createObservabilityServerRoute } from '../create_observability_server_route';
@ -59,6 +60,7 @@ const transformGenerators: Record<IndicatorTypes, TransformGenerator> = {
'sli.kql.custom': new KQLCustomTransformGenerator(),
'sli.metric.custom': new MetricCustomTransformGenerator(),
'sli.histogram.custom': new HistogramTransformGenerator(),
'sli.metric.timeslice': new TimesliceMetricTransformGenerator(),
};
const assertPlatinumLicense = async (context: ObservabilityRequestHandlerContext) => {

View file

@ -0,0 +1,213 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GetTimesliceMetricIndicatorAggregation should generate an aggregation for basic metrics 1`] = `
Object {
"_A": Object {
"aggs": Object {
"metric": Object {
"avg": Object {
"field": "test.field",
},
},
},
"filter": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"test.category": "test",
},
},
],
},
},
},
"_B": Object {
"aggs": Object {
"metric": Object {
"max": Object {
"field": "test.field",
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"_C": Object {
"aggs": Object {
"metric": Object {
"min": Object {
"field": "test.field",
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"_D": Object {
"aggs": Object {
"metric": Object {
"sum": Object {
"field": "test.field",
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"_E": Object {
"aggs": Object {
"metric": Object {
"cardinality": Object {
"field": "test.field",
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"_metric": Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_A>metric",
"B": "_B>metric",
"C": "_C>metric",
"D": "_D>metric",
"E": "_E>metric",
},
"script": Object {
"lang": "painless",
"source": "(params.A + params.B + params.C + params.D + params.E) / params.A",
},
},
},
}
`;
exports[`GetTimesliceMetricIndicatorAggregation should generate an aggregation for doc_count 1`] = `
Object {
"_A": Object {
"filter": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"test.category": "test",
},
},
],
},
},
},
"_metric": Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_A>_count",
},
"script": Object {
"lang": "painless",
"source": "params.A",
},
},
},
}
`;
exports[`GetTimesliceMetricIndicatorAggregation should generate an aggregation for last_value 1`] = `
Object {
"_A": Object {
"aggs": Object {
"metric": Object {
"top_metrics": Object {
"metrics": Object {
"field": "test.field",
},
"sort": Object {
"@timestamp": "desc",
},
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"_metric": Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_A>metric[test.field]",
},
"script": Object {
"lang": "painless",
"source": "params.A",
},
},
},
}
`;
exports[`GetTimesliceMetricIndicatorAggregation should generate an aggregation for percentile 1`] = `
Object {
"_A": Object {
"aggs": Object {
"metric": Object {
"percentiles": Object {
"field": "test.field",
"keyed": true,
"percents": Array [
97,
],
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"_metric": Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_A>metric[97]",
},
"script": Object {
"lang": "painless",
"source": "params.A",
},
},
},
}
`;
exports[`GetTimesliceMetricIndicatorAggregation should generate an aggregation for std_deviation 1`] = `
Object {
"_A": Object {
"aggs": Object {
"metric": Object {
"extended_stats": Object {
"field": "test.field",
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"_metric": Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_A>metric[std_deviation]",
},
"script": Object {
"lang": "painless",
"source": "params.A",
},
},
},
}
`;

View file

@ -37,7 +37,7 @@ export class GetCustomMetricIndicatorAggregation {
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}`);
return acc.replaceAll(key, `params.${key}`);
}, workingEquation);
}

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createTimesliceMetricIndicator } from '../fixtures/slo';
import { GetTimesliceMetricIndicatorAggregation } from './get_timeslice_metric_indicator_aggregation';
describe('GetTimesliceMetricIndicatorAggregation', () => {
it('should generate an aggregation for basic metrics', () => {
const indicator = createTimesliceMetricIndicator(
[
{
name: 'A',
aggregation: 'avg' as const,
field: 'test.field',
filter: 'test.category: test',
},
{
name: 'B',
aggregation: 'max' as const,
field: 'test.field',
},
{
name: 'C',
aggregation: 'min' as const,
field: 'test.field',
},
{
name: 'D',
aggregation: 'sum' as const,
field: 'test.field',
},
{
name: 'E',
aggregation: 'cardinality' as const,
field: 'test.field',
},
],
'(A + B + C + D + E) / A'
);
const getIndicatorAggregation = new GetTimesliceMetricIndicatorAggregation(indicator);
expect(getIndicatorAggregation.execute('_metric')).toMatchSnapshot();
});
it('should generate an aggregation for doc_count', () => {
const indicator = createTimesliceMetricIndicator(
[
{
name: 'A',
aggregation: 'doc_count' as const,
filter: 'test.category: test',
},
],
'A'
);
const getIndicatorAggregation = new GetTimesliceMetricIndicatorAggregation(indicator);
expect(getIndicatorAggregation.execute('_metric')).toMatchSnapshot();
});
it('should generate an aggregation for std_deviation', () => {
const indicator = createTimesliceMetricIndicator(
[
{
name: 'A',
aggregation: 'std_deviation' as const,
field: 'test.field',
},
],
'A'
);
const getIndicatorAggregation = new GetTimesliceMetricIndicatorAggregation(indicator);
expect(getIndicatorAggregation.execute('_metric')).toMatchSnapshot();
});
it('should generate an aggregation for percentile', () => {
const indicator = createTimesliceMetricIndicator(
[
{
name: 'A',
aggregation: 'percentile' as const,
field: 'test.field',
percentile: 97,
},
],
'A'
);
const getIndicatorAggregation = new GetTimesliceMetricIndicatorAggregation(indicator);
expect(getIndicatorAggregation.execute('_metric')).toMatchSnapshot();
});
it('should generate an aggregation for last_value', () => {
const indicator = createTimesliceMetricIndicator(
[
{
name: 'A',
aggregation: 'last_value' as const,
field: 'test.field',
},
],
'A'
);
const getIndicatorAggregation = new GetTimesliceMetricIndicatorAggregation(indicator);
expect(getIndicatorAggregation.execute('_metric')).toMatchSnapshot();
});
});

View file

@ -0,0 +1,130 @@
/*
* 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 { TimesliceMetricIndicator, timesliceMetricMetricDef } from '@kbn/slo-schema';
import * as t from 'io-ts';
import { assertNever } from '@kbn/std';
import { getElastichsearchQueryOrThrow } from '../transform_generators';
type TimesliceMetricDef = TimesliceMetricIndicator['params']['metric'];
type TimesliceMetricMetricDef = t.TypeOf<typeof timesliceMetricMetricDef>;
export class GetTimesliceMetricIndicatorAggregation {
constructor(private indicator: TimesliceMetricIndicator) {}
private buildAggregation(metric: TimesliceMetricMetricDef) {
const { aggregation } = metric;
switch (aggregation) {
case 'doc_count':
return {};
case 'std_deviation':
return {
extended_stats: { field: metric.field },
};
case 'percentile':
if (metric.percentile == null) {
throw new Error('You must provide a percentile value for percentile aggregations.');
}
return {
percentiles: {
field: metric.field,
percents: [metric.percentile],
keyed: true,
},
};
case 'last_value':
return {
top_metrics: {
metrics: { field: metric.field },
sort: { [this.indicator.params.timestampField]: 'desc' },
},
};
case 'avg':
case 'max':
case 'min':
case 'sum':
case 'cardinality':
if (metric.field == null) {
throw new Error('You must provide a field for basic metric aggregations.');
}
return {
[aggregation]: { field: metric.field },
};
default:
assertNever(aggregation);
}
}
private buildBucketPath(prefix: string, metric: TimesliceMetricMetricDef) {
const { aggregation } = metric;
switch (aggregation) {
case 'doc_count':
return `${prefix}>_count`;
case 'std_deviation':
return `${prefix}>metric[std_deviation]`;
case 'percentile':
return `${prefix}>metric[${metric.percentile}]`;
case 'last_value':
return `${prefix}>metric[${metric.field}]`;
case 'avg':
case 'max':
case 'min':
case 'sum':
case 'cardinality':
return `${prefix}>metric`;
default:
assertNever(aggregation);
}
}
private buildMetricAggregations(metricDef: TimesliceMetricDef) {
return metricDef.metrics.reduce((acc, metric) => {
const filter = metric.filter
? getElastichsearchQueryOrThrow(metric.filter)
: { match_all: {} };
const aggs = { metric: this.buildAggregation(metric) };
return {
...acc,
[`_${metric.name}`]: {
filter,
...(metric.aggregation !== 'doc_count' ? { aggs } : {}),
},
};
}, {});
}
private convertEquationToPainless(bucketsPath: Record<string, string>, equation: string) {
const workingEquation = equation || Object.keys(bucketsPath).join(' + ');
return Object.keys(bucketsPath).reduce((acc, key) => {
return acc.replaceAll(key, `params.${key}`);
}, workingEquation);
}
private buildMetricEquation(definition: TimesliceMetricDef) {
const bucketsPath = definition.metrics.reduce(
(acc, metric) => ({ ...acc, [metric.name]: this.buildBucketPath(`_${metric.name}`, metric) }),
{}
);
return {
bucket_script: {
buckets_path: bucketsPath,
script: {
source: this.convertEquationToPainless(bucketsPath, definition.equation),
lang: 'painless',
},
},
};
}
public execute(aggregationKey: string) {
return {
...this.buildMetricAggregations(this.indicator.params.metric),
[aggregationKey]: this.buildMetricEquation(this.indicator.params.metric),
};
}
}

View file

@ -7,3 +7,4 @@
export * from './get_histogram_indicator_aggregation';
export * from './get_custom_metric_indicator_aggregation';
export * from './get_timeslice_metric_indicator_aggregation';

View file

@ -6,7 +6,13 @@
*/
import { SavedObject } from '@kbn/core-saved-objects-server';
import { ALL_VALUE, CreateSLOParams, HistogramIndicator, sloSchema } from '@kbn/slo-schema';
import {
ALL_VALUE,
CreateSLOParams,
HistogramIndicator,
sloSchema,
TimesliceMetricIndicator,
} from '@kbn/slo-schema';
import { cloneDeep } from 'lodash';
import { v1 as uuidv1 } from 'uuid';
import {
@ -90,6 +96,25 @@ export const createMetricCustomIndicator = (
},
});
export const createTimesliceMetricIndicator = (
metrics: TimesliceMetricIndicator['params']['metric']['metrics'] = [],
equation: TimesliceMetricIndicator['params']['metric']['equation'] = '',
queryFilter = ''
): TimesliceMetricIndicator => ({
type: 'sli.metric.timeslice',
params: {
index: 'test-*',
timestampField: '@timestamp',
filter: queryFilter,
metric: {
metrics,
equation,
threshold: 100,
comparator: 'GTE',
},
},
});
export const createHistogramIndicator = (
params: Partial<HistogramIndicator['params']> = {}
): HistogramIndicator => ({

View file

@ -15,6 +15,7 @@ import {
HistogramIndicator,
KQLCustomIndicator,
MetricCustomIndicator,
TimesliceMetricIndicator,
} from '@kbn/slo-schema';
import { assertNever } from '@kbn/std';
import { APMTransactionDurationIndicator } from '../../domain/models';
@ -23,6 +24,7 @@ import { InvalidQueryError } from '../../errors';
import {
GetCustomMetricIndicatorAggregation,
GetHistogramIndicatorAggregation,
GetTimesliceMetricIndicatorAggregation,
} from './aggregations';
export class GetPreviewData {
@ -55,6 +57,7 @@ export class GetPreviewData {
const result = await this.esClient.search({
index: indicator.params.index,
size: 0,
query: {
bool: {
filter: [
@ -130,6 +133,7 @@ export class GetPreviewData {
const result = await this.esClient.search({
index: indicator.params.index,
size: 0,
query: {
bool: {
filter: [
@ -186,6 +190,7 @@ export class GetPreviewData {
const timestampField = indicator.params.timestampField;
const options = {
index: indicator.params.index,
size: 0,
query: {
bool: {
filter: [{ range: { [timestampField]: { gte: 'now-60m' } } }, filterQuery],
@ -228,6 +233,7 @@ export class GetPreviewData {
const getCustomMetricIndicatorAggregation = new GetCustomMetricIndicatorAggregation(indicator);
const result = await this.esClient.search({
index: indicator.params.index,
size: 0,
query: {
bool: {
filter: [{ range: { [timestampField]: { gte: 'now-60m' } } }, filterQuery],
@ -261,6 +267,42 @@ export class GetPreviewData {
}));
}
private async getTimesliceMetricPreviewData(
indicator: TimesliceMetricIndicator
): Promise<GetPreviewDataResponse> {
const timestampField = indicator.params.timestampField;
const filterQuery = getElastichsearchQueryOrThrow(indicator.params.filter);
const getCustomMetricIndicatorAggregation = new GetTimesliceMetricIndicatorAggregation(
indicator
);
const result = await this.esClient.search({
index: indicator.params.index,
size: 0,
query: {
bool: {
filter: [{ range: { [timestampField]: { gte: 'now-60m' } } }, filterQuery],
},
},
aggs: {
perMinute: {
date_histogram: {
field: timestampField,
fixed_interval: '1m',
},
aggs: {
...getCustomMetricIndicatorAggregation.execute('metric'),
},
},
},
});
// @ts-ignore buckets is not improperly typed
return result.aggregations?.perMinute.buckets.map((bucket) => ({
date: bucket.key_as_string,
sliValue: !!bucket.metric ? bucket.metric.value : null,
}));
}
private async getCustomKQLPreviewData(
indicator: KQLCustomIndicator
): Promise<GetPreviewDataResponse> {
@ -270,6 +312,7 @@ export class GetPreviewData {
const timestampField = indicator.params.timestampField;
const result = await this.esClient.search({
index: indicator.params.index,
size: 0,
query: {
bool: {
filter: [{ range: { [timestampField]: { gte: 'now-60m' } } }, filterQuery],
@ -313,6 +356,8 @@ export class GetPreviewData {
return this.getHistogramPreviewData(params.indicator);
case 'sli.metric.custom':
return this.getCustomMetricPreviewData(params.indicator);
case 'sli.metric.timeslice':
return this.getTimesliceMetricPreviewData(params.indicator);
default:
assertNever(type);
}

View file

@ -0,0 +1,697 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Timeslice Metric Transform Generator filters the source using the kql query 1`] = `
Object {
"bool": Object {
"filter": Array [
Object {
"range": Object {
"@timestamp": Object {
"gte": "now-7d/d",
},
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match_phrase": Object {
"test.category": "test",
},
},
],
},
},
],
},
}
`;
exports[`Timeslice Metric Transform Generator returns the expected transform params for timeslices slo 1`] = `
Object {
"_meta": Object {
"managed": true,
"managed_by": "observability",
"version": 2,
},
"description": "Rolled-up SLI data for SLO: irrelevant",
"dest": Object {
"index": ".slo-observability.sli-v2",
"pipeline": ".slo-observability.sli.pipeline",
},
"frequency": "1m",
"pivot": Object {
"aggregations": Object {
"_A": Object {
"aggs": Object {
"metric": Object {
"avg": Object {
"field": "test.field",
},
},
},
"filter": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match_phrase": Object {
"test.category": "test",
},
},
],
},
},
},
"_B": Object {
"filter": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match_phrase": Object {
"test.category": "test",
},
},
],
},
},
},
"_C": Object {
"aggs": Object {
"metric": Object {
"top_metrics": Object {
"metrics": Object {
"field": "test.field",
},
"sort": Object {
"@timestamp": "desc",
},
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"_D": Object {
"aggs": Object {
"metric": Object {
"extended_stats": Object {
"field": "test.field",
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"_E": Object {
"aggs": Object {
"metric": Object {
"percentiles": Object {
"field": "test.field",
"keyed": true,
"percents": Array [
97,
],
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"_metric": Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_A>metric",
"B": "_B>_count",
"C": "_C>metric[test.field]",
"D": "_D>metric[std_deviation]",
"E": "_E>metric[97]",
},
"script": Object {
"lang": "painless",
"source": "(params.A + params.B + params.C + params.D + params.E) / params.B",
},
},
},
"slo.denominator": Object {
"bucket_script": Object {
"buckets_path": Object {},
"script": "1",
},
},
"slo.isGoodSlice": Object {
"bucket_script": Object {
"buckets_path": Object {
"goodEvents": "slo.numerator>value",
},
"script": "params.goodEvents == 1 ? 1 : 0",
},
},
"slo.numerator": Object {
"bucket_script": Object {
"buckets_path": Object {
"value": "_metric>value",
},
"script": Object {
"params": Object {
"threshold": 100,
},
"source": "params.value >= params.threshold ? 1 : 0",
},
},
},
},
"group_by": Object {
"@timestamp": Object {
"date_histogram": Object {
"field": "@timestamp",
"fixed_interval": "2m",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.sliceDurationInSeconds": Object {
"terms": Object {
"field": "slo.objective.sliceDurationInSeconds",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
},
},
"settings": Object {
"deduce_mappings": false,
"unattended": true,
},
"source": Object {
"index": "test-*",
"query": Object {
"bool": Object {
"filter": Array [
Object {
"range": Object {
"@timestamp": Object {
"gte": "now-7d/d",
},
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match_phrase": Object {
"test.category": "test",
},
},
],
},
},
],
},
},
"runtime_mappings": Object {
"slo.budgetingMethod": Object {
"script": Object {
"source": "emit('timeslices')",
},
"type": "keyword",
},
"slo.description": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.groupBy": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
},
"type": "keyword",
},
"slo.indicator.type": Object {
"script": Object {
"source": "emit('sli.metric.timeslice')",
},
"type": "keyword",
},
"slo.instanceId": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.name": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.objective.sliceDurationInSeconds": Object {
"script": Object {
"source": "emit(120)",
},
"type": "long",
},
"slo.objective.target": Object {
"script": Object {
"source": "emit(0.98)",
},
"type": "double",
},
"slo.revision": Object {
"script": Object {
"source": "emit(1)",
},
"type": "long",
},
"slo.tags": Object {
"script": Object {
"source": "emit('critical,k8s')",
},
"type": "keyword",
},
"slo.timeWindow.duration": Object {
"script": Object {
"source": "emit('7d')",
},
"type": "keyword",
},
"slo.timeWindow.type": Object {
"script": Object {
"source": "emit('rolling')",
},
"type": "keyword",
},
},
},
"sync": Object {
"time": Object {
"delay": "1m",
"field": "@timestamp",
},
},
"transform_id": Any<String>,
}
`;
exports[`Timeslice Metric Transform Generator returns the expected transform params with every specified indicator params 1`] = `
Object {
"_meta": Object {
"managed": true,
"managed_by": "observability",
"version": 2,
},
"description": "Rolled-up SLI data for SLO: irrelevant",
"dest": Object {
"index": ".slo-observability.sli-v2",
"pipeline": ".slo-observability.sli.pipeline",
},
"frequency": "1m",
"pivot": Object {
"aggregations": Object {
"_A": Object {
"aggs": Object {
"metric": Object {
"avg": Object {
"field": "test.field",
},
},
},
"filter": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match_phrase": Object {
"test.category": "test",
},
},
],
},
},
},
"_B": Object {
"filter": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match_phrase": Object {
"test.category": "test",
},
},
],
},
},
},
"_C": Object {
"aggs": Object {
"metric": Object {
"top_metrics": Object {
"metrics": Object {
"field": "test.field",
},
"sort": Object {
"@timestamp": "desc",
},
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"_D": Object {
"aggs": Object {
"metric": Object {
"extended_stats": Object {
"field": "test.field",
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"_E": Object {
"aggs": Object {
"metric": Object {
"percentiles": Object {
"field": "test.field",
"keyed": true,
"percents": Array [
97,
],
},
},
},
"filter": Object {
"match_all": Object {},
},
},
"_metric": Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_A>metric",
"B": "_B>_count",
"C": "_C>metric[test.field]",
"D": "_D>metric[std_deviation]",
"E": "_E>metric[97]",
},
"script": Object {
"lang": "painless",
"source": "(params.A + params.B + params.C + params.D + params.E) / params.B",
},
},
},
"slo.denominator": Object {
"bucket_script": Object {
"buckets_path": Object {},
"script": "1",
},
},
"slo.isGoodSlice": Object {
"bucket_script": Object {
"buckets_path": Object {
"goodEvents": "slo.numerator>value",
},
"script": "params.goodEvents == 1 ? 1 : 0",
},
},
"slo.numerator": Object {
"bucket_script": Object {
"buckets_path": Object {
"value": "_metric>value",
},
"script": Object {
"params": Object {
"threshold": 100,
},
"source": "params.value >= params.threshold ? 1 : 0",
},
},
},
},
"group_by": Object {
"@timestamp": Object {
"date_histogram": Object {
"field": "@timestamp",
"fixed_interval": "2m",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.sliceDurationInSeconds": Object {
"terms": Object {
"field": "slo.objective.sliceDurationInSeconds",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
},
},
"settings": Object {
"deduce_mappings": false,
"unattended": true,
},
"source": Object {
"index": "test-*",
"query": Object {
"bool": Object {
"filter": Array [
Object {
"range": Object {
"@timestamp": Object {
"gte": "now-7d/d",
},
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match_phrase": Object {
"test.category": "test",
},
},
],
},
},
],
},
},
"runtime_mappings": Object {
"slo.budgetingMethod": Object {
"script": Object {
"source": "emit('timeslices')",
},
"type": "keyword",
},
"slo.description": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.groupBy": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
},
"type": "keyword",
},
"slo.indicator.type": Object {
"script": Object {
"source": "emit('sli.metric.timeslice')",
},
"type": "keyword",
},
"slo.instanceId": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.name": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.objective.sliceDurationInSeconds": Object {
"script": Object {
"source": "emit(120)",
},
"type": "long",
},
"slo.objective.target": Object {
"script": Object {
"source": "emit(0.98)",
},
"type": "double",
},
"slo.revision": Object {
"script": Object {
"source": "emit(1)",
},
"type": "long",
},
"slo.tags": Object {
"script": Object {
"source": "emit('critical,k8s')",
},
"type": "keyword",
},
"slo.timeWindow.duration": Object {
"script": Object {
"source": "emit('7d')",
},
"type": "keyword",
},
"slo.timeWindow.type": Object {
"script": Object {
"source": "emit('rolling')",
},
"type": "keyword",
},
},
},
"sync": Object {
"time": Object {
"delay": "1m",
"field": "@timestamp",
},
},
"transform_id": Any<String>,
}
`;

View file

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

View file

@ -0,0 +1,178 @@
/*
* 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 {
createTimesliceMetricIndicator,
createSLOWithTimeslicesBudgetingMethod,
createSLO,
} from '../fixtures/slo';
import { TimesliceMetricTransformGenerator } from './timeslice_metric';
const generator = new TimesliceMetricTransformGenerator();
const everythingIndicator = createTimesliceMetricIndicator(
[
{ name: 'A', aggregation: 'avg', field: 'test.field', filter: 'test.category: "test"' },
{ name: 'B', aggregation: 'doc_count', filter: 'test.category: "test"' },
{ name: 'C', aggregation: 'last_value', field: 'test.field' },
{ name: 'D', aggregation: 'std_deviation', field: 'test.field' },
{ name: 'E', aggregation: 'percentile', field: 'test.field', percentile: 97 },
],
'(A + B + C + D + E) / B',
'test.category: "test"'
);
describe('Timeslice Metric Transform Generator', () => {
describe('validation', () => {
it('throws when the budgeting method is occurrences', () => {
const anSLO = createSLO({
indicator: createTimesliceMetricIndicator(
[{ name: 'A', aggregation: 'avg', field: 'test.field' }],
'(A / 200) + A'
),
});
expect(() => generator.getTransformParams(anSLO)).toThrow(
'The sli.metric.timeslice indicator MUST have a timeslice budgeting method.'
);
});
it('throws when the metric equation is invalid', () => {
const anSLO = createSLOWithTimeslicesBudgetingMethod({
indicator: createTimesliceMetricIndicator(
[{ name: 'A', aggregation: 'avg', field: 'test.field' }],
'(a / 200) + A'
),
});
expect(() => generator.getTransformParams(anSLO)).toThrow(/Invalid equation/);
});
it('throws when the metric filter is invalid', () => {
const anSLO = createSLOWithTimeslicesBudgetingMethod({
indicator: createTimesliceMetricIndicator(
[{ name: 'A', aggregation: 'avg', field: 'test.field', filter: 'test:' }],
'(A / 200) + A'
),
});
expect(() => generator.getTransformParams(anSLO)).toThrow(/Invalid KQL: test:/);
});
it('throws when the query_filter is invalid', () => {
const anSLO = createSLOWithTimeslicesBudgetingMethod({
indicator: createTimesliceMetricIndicator(
[{ name: 'A', aggregation: 'avg', field: 'test.field', filter: 'test.category: "test"' }],
'(A / 200) + A',
'test:'
),
});
expect(() => generator.getTransformParams(anSLO)).toThrow(/Invalid KQL/);
});
});
it('returns the expected transform params with every specified indicator params', async () => {
const anSLO = createSLOWithTimeslicesBudgetingMethod({
indicator: everythingIndicator,
});
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: everythingIndicator,
});
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 = createSLOWithTimeslicesBudgetingMethod({
indicator: everythingIndicator,
});
const transform = generator.getTransformParams(anSLO);
expect(transform.source.query).toMatchSnapshot();
});
it('uses the provided index', async () => {
const anSLO = createSLOWithTimeslicesBudgetingMethod({
indicator: {
...everythingIndicator,
params: { ...everythingIndicator.params, 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 = createSLOWithTimeslicesBudgetingMethod({
indicator: {
...everythingIndicator,
params: { ...everythingIndicator.params, 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 _metric equation', async () => {
const anSLO = createSLOWithTimeslicesBudgetingMethod({
indicator: everythingIndicator,
});
const transform = generator.getTransformParams(anSLO);
expect(transform.pivot!.aggregations!._metric).toEqual({
bucket_script: {
buckets_path: {
A: '_A>metric',
B: '_B>_count',
C: '_C>metric[test.field]',
D: '_D>metric[std_deviation]',
E: '_E>metric[97]',
},
script: {
lang: 'painless',
source: '(params.A + params.B + params.C + params.D + params.E) / params.B',
},
},
});
expect(transform.pivot!.aggregations!['slo.numerator']).toEqual({
bucket_script: {
buckets_path: {
value: '_metric>value',
},
script: {
params: {
threshold: 100,
},
source: 'params.value >= params.threshold ? 1 : 0',
},
},
});
expect(transform.pivot!.aggregations!['slo.denominator']).toEqual({
bucket_script: {
buckets_path: {},
script: '1',
},
});
});
});

View file

@ -0,0 +1,116 @@
/*
* 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 {
timesliceMetricComparatorMapping,
TimesliceMetricIndicator,
timesliceMetricIndicatorSchema,
timeslicesBudgetingMethodSchema,
} from '@kbn/slo-schema';
import { InvalidTransformError } from '../../../errors';
import { getSLOTransformTemplate } from '../../../assets/transform_templates/slo_transform_template';
import { getElastichsearchQueryOrThrow, parseIndex, TransformGenerator } from '.';
import {
SLO_DESTINATION_INDEX_NAME,
SLO_INGEST_PIPELINE_NAME,
getSLOTransformId,
} from '../../../assets/constants';
import { SLO } from '../../../domain/models';
import { GetTimesliceMetricIndicatorAggregation } from '../aggregations';
const INVALID_EQUATION_REGEX = /[^A-Z|+|\-|\s|\d+|\.|\(|\)|\/|\*|>|<|=|\?|\:|&|\!|\|]+/g;
export class TimesliceMetricTransformGenerator extends TransformGenerator {
public getTransformParams(slo: SLO): TransformPutTransformRequest {
if (!timesliceMetricIndicatorSchema.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.buildCommonGroupBy(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: TimesliceMetricIndicator) {
return {
index: parseIndex(indicator.params.index),
runtime_mappings: this.buildCommonRuntimeMappings(slo),
query: {
bool: {
filter: [
{
range: {
[indicator.params.timestampField]: {
gte: `now-${slo.timeWindow.duration.format()}/d`,
},
},
},
getElastichsearchQueryOrThrow(indicator.params.filter),
],
},
},
};
}
private buildDestination() {
return {
pipeline: SLO_INGEST_PIPELINE_NAME,
index: SLO_DESTINATION_INDEX_NAME,
};
}
private buildAggregations(slo: SLO, indicator: TimesliceMetricIndicator) {
if (indicator.params.metric.equation.match(INVALID_EQUATION_REGEX)) {
throw new Error(`Invalid equation: ${indicator.params.metric.equation}`);
}
if (!timeslicesBudgetingMethodSchema.is(slo.budgetingMethod)) {
throw new Error('The sli.metric.timeslice indicator MUST have a timeslice budgeting method.');
}
const getIndicatorAggregation = new GetTimesliceMetricIndicatorAggregation(indicator);
const comparator = timesliceMetricComparatorMapping[indicator.params.metric.comparator];
return {
...getIndicatorAggregation.execute('_metric'),
'slo.numerator': {
bucket_script: {
buckets_path: { value: '_metric>value' },
script: {
source: `params.value ${comparator} params.threshold ? 1 : 0`,
params: { threshold: indicator.params.metric.threshold },
},
},
},
'slo.denominator': {
bucket_script: {
buckets_path: {},
script: '1',
},
},
'slo.isGoodSlice': {
bucket_script: {
buckets_path: {
goodEvents: 'slo.numerator>value',
},
script: `params.goodEvents == 1 ? 1 : 0`,
},
},
};
}
}