mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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:
parent
2932b77eec
commit
befbe10fd3
38 changed files with 3225 additions and 29 deletions
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
@ -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"'
|
|
@ -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"'
|
||||
|
|
@ -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"'
|
|
@ -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:
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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`);
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
`;
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -7,3 +7,4 @@
|
|||
|
||||
export * from './get_histogram_indicator_aggregation';
|
||||
export * from './get_custom_metric_indicator_aggregation';
|
||||
export * from './get_timeslice_metric_indicator_aggregation';
|
||||
|
|
|
@ -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 => ({
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>,
|
||||
}
|
||||
`;
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue