feat(slo): Add timestampField additional settings (#153395)

This commit is contained in:
Kevin Delemme 2023-03-23 10:03:33 -04:00 committed by GitHub
parent b0b50f2978
commit c0453af53b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 453 additions and 363 deletions

View file

@ -50,12 +50,17 @@ const apmTransactionErrorRateIndicatorSchema = t.type({
const kqlCustomIndicatorTypeSchema = t.literal('sli.kql.custom');
const kqlCustomIndicatorSchema = t.type({
type: kqlCustomIndicatorTypeSchema,
params: t.type({
index: t.string,
filter: t.string,
good: t.string,
total: t.string,
}),
params: t.intersection([
t.type({
index: t.string,
filter: t.string,
good: t.string,
total: t.string,
}),
t.partial({
timestampField: t.string,
}),
]),
});
const indicatorDataSchema = t.type({

View file

@ -26,7 +26,6 @@ const objectiveSchema = t.intersection([
]);
const settingsSchema = t.type({
timestampField: t.string,
syncDelay: durationType,
frequency: durationType,
});

View file

@ -134,7 +134,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"siem-ui-timeline": "e9d6b3a9fd7af6dc502293c21cbdb309409f3996",
"siem-ui-timeline-note": "13c9d4c142f96624a93a623c6d7cba7e1ae9b5a6",
"siem-ui-timeline-pinned-event": "96a43d59b9e2fc11f12255a0cb47ef0a3d83af4c",
"slo": "06733daaa5fbe331fdf3b515171978aff483ccf2",
"slo": "af2a119f3bceee5f12dc02d7eb4f54040bae4557",
"space": "7fc578a1f9f7708cb07479f03953d664ad9f1dae",
"spaces-usage-stats": "084bd0f080f94fb5735d7f3cf12f13ec92f36bad",
"synthetics-monitor": "96cc312bfa597022f83dfb3b5d1501e27a73e8d5",

View file

@ -12,7 +12,7 @@ We currently support the following SLI:
For the APM SLIs, customer can provide the service, environment, transaction name and type to configure them. For the **APM Latency** SLI, a threshold in milliseconds needs to be provided to discriminate the good and bad responses (events). For the **APM Availability** SLI, a list of good status codes needs to be provided to discriminate the good and bad responses (events). The API supports an optional kql filter to further filter the apm data.
The **custom KQL** SLI requires an index pattern, an optional filter query, a numerator query, and denominator query.
The **custom KQL** SLI requires an index pattern, an optional filter query, a numerator query, and denominator query. A custom 'timestampField' can be provided to override the default @timestamp field.
## SLO configuration
@ -43,7 +43,6 @@ If a **timeslices** budgeting method is used, we also need to define the **times
The default settings should be sufficient for most users, but if needed, the following properties can be overwritten:
- **timestampField**: The date time field to use from the source index
- **syncDelay**: The ingest delay in the source data
- **frequency**: How often do we query the source data
@ -299,7 +298,8 @@ curl --request POST \
"index": "high-cardinality-data-fake_logs*",
"good": "latency < 300",
"total": "",
"filter": "labels.groupId: group-0"
"filter": "labels.groupId: group-0",
"timestampField": "custom_timestamp"
}
},
"timeWindow": {

View file

@ -379,6 +379,11 @@ components:
description: the KQL query used to define all events.
type: string
example: ''
timestampField:
description: |
The timestamp field used in the source indice. If not specified, @timestamp will be used.
type: string
example: timestamp
type:
description: The type of indicator.
type: string
@ -554,11 +559,6 @@ components:
description: Defines properties for settings.
type: object
properties:
timestampField:
description: |
The timestamp field used in the source indice. Particularly useful for custom kql indicator type, when the index does not use the default '@timestamp' field
type: string
example: timestamp
syncDelay:
description: The synch delay to apply to the transform. Default 1m
type: string

View file

@ -28,6 +28,11 @@ properties:
description: the KQL query used to define all events.
type: string
example: ''
timestampField:
description: >
The timestamp field used in the source indice. If not specified, @timestamp will be used.
type: string
example: timestamp
type:
description: The type of indicator.
type: string

View file

@ -2,12 +2,6 @@ title: Settings definition
description: Defines properties for settings.
type: object
properties:
timestampField:
description: >
The timestamp field used in the source indice. Particularly useful for custom kql indicator type, when the index
does not use the default '@timestamp' field
type: string
example: timestamp
syncDelay:
description: The synch delay to apply to the transform. Default 1m
type: string

View file

@ -38,6 +38,7 @@ const baseSlo: Omit<SLOWithSummaryResponse, 'id'> = {
filter: 'baz: foo and bar > 2',
good: 'http_status: 2xx',
total: 'a query',
timestampField: 'custom_timestamp',
},
},
timeWindow: {
@ -48,7 +49,6 @@ const baseSlo: Omit<SLOWithSummaryResponse, 'id'> = {
budgetingMethod: 'occurrences',
revision: 1,
settings: {
timestampField: '@timestamp',
syncDelay: '1m',
frequency: '1m',
},

View file

@ -5,10 +5,17 @@
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useState } from 'react';
import {
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormLabel,
EuiIcon,
EuiLink,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useFormContext } from 'react-hook-form';
import { Controller, useFormContext } from 'react-hook-form';
import { CreateSLOInput } from '@kbn/slo-schema';
import { IndexSelection } from './index_selection';
@ -16,6 +23,12 @@ import { QueryBuilder } from '../common/query_builder';
export function CustomKqlIndicatorTypeForm() {
const { control, watch } = useFormContext<CreateSLOInput>();
const [isAdditionalSettingsOpen, setAdditionalSettingsOpen] = useState<boolean>(false);
const handleAdditionalSettingsClick = () => {
setAdditionalSettingsOpen(!isAdditionalSettingsOpen);
};
return (
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexItem>
@ -75,6 +88,48 @@ export function CustomKqlIndicatorTypeForm() {
)}
/>
</EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexItem>
<EuiLink
data-test-subj="customKqlIndicatorFormAdditionalSettingsToggle"
onClick={handleAdditionalSettingsClick}
>
<EuiIcon type={isAdditionalSettingsOpen ? 'arrowDown' : 'arrowRight'} />{' '}
{i18n.translate('xpack.observability.slo.sloEdit.sliType.additionalSettings.label', {
defaultMessage: 'Additional settings',
})}
</EuiLink>
</EuiFlexItem>
{isAdditionalSettingsOpen && (
<EuiFlexItem>
<EuiFormLabel>
{i18n.translate(
'xpack.observability.slo.sloEdit.additionalSettings.timestampField.label',
{ defaultMessage: 'Timestamp field' }
)}
</EuiFormLabel>
<Controller
name="indicator.params.timestampField"
shouldUnregister
control={control}
render={({ field: { ref, ...field } }) => (
<EuiFieldText
{...field}
disabled={!watch('indicator.params.index')}
data-test-subj="sloFormAdditionalSettingsTimestampField"
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.additionalSettings.timestampField.placeholder',
{ defaultMessage: 'Timestamp field used in the index, default to @timestamp' }
)}
/>
)}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexGroup>
);
}

View file

@ -6,45 +6,32 @@
*/
import React from 'react';
import {
EuiAvatar,
EuiButton,
EuiFlexGroup,
EuiFormLabel,
EuiPanel,
EuiSelect,
EuiSpacer,
EuiTimeline,
EuiTimelineItem,
EuiTitle,
} from '@elastic/eui';
import { EuiAvatar, EuiButton, EuiFlexGroup, EuiTimeline, EuiTimelineItem } from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import type { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useKibana } from '../../../utils/kibana_react';
import { useCreateSlo } from '../../../hooks/slo/use_create_slo';
import { useUpdateSlo } from '../../../hooks/slo/use_update_slo';
import { useSectionFormValidation } from '../helpers/use_section_form_validation';
import { CustomKqlIndicatorTypeForm } from './custom_kql/custom_kql_indicator_type_form';
import { SloEditFormDescription } from './slo_edit_form_description';
import { SloEditFormObjectives } from './slo_edit_form_objectives';
import { SloEditFormDescriptionSection } from './slo_edit_form_description_section';
import { SloEditFormObjectiveSection } from './slo_edit_form_objective_section';
import {
transformValuesToCreateSLOInput,
transformSloResponseToCreateSloInput,
transformValuesToUpdateSLOInput,
} from '../helpers/process_slo_form_values';
import { paths } from '../../../config/paths';
import { SLI_OPTIONS, SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
import { ApmLatencyIndicatorTypeForm } from './apm_latency/apm_latency_indicator_type_form';
import { ApmAvailabilityIndicatorTypeForm } from './apm_availability/apm_availability_indicator_type_form';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
import { SloEditFormIndicatorSection } from './slo_edit_form_indicator_section';
export interface Props {
slo: SLOWithSummaryResponse | undefined;
}
const maxWidth = 775;
export const maxWidth = 775;
export function SloEditForm({ slo }: Props) {
const {
@ -58,7 +45,7 @@ export function SloEditForm({ slo }: Props) {
values: transformSloResponseToCreateSloInput(slo),
mode: 'all',
});
const { control, watch, getFieldState, getValues, formState } = methods;
const { watch, getFieldState, getValues, formState } = methods;
const { isIndicatorSectionValid, isDescriptionSectionValid, isObjectiveSectionValid } =
useSectionFormValidation({
@ -120,18 +107,8 @@ export function SloEditForm({ slo }: Props) {
}
};
const getIndicatorTypeForm = () => {
switch (watch('indicator.type')) {
case 'sli.kql.custom':
return <CustomKqlIndicatorTypeForm />;
case 'sli.apm.transactionDuration':
return <ApmLatencyIndicatorTypeForm />;
case 'sli.apm.transactionErrorRate':
return <ApmAvailabilityIndicatorTypeForm />;
default:
return null;
}
};
const getIconColor = (isSectionValid: boolean) =>
isSectionValid ? euiThemeVars.euiColorSuccess : euiThemeVars.euiColorPrimary;
return (
<FormProvider {...methods}>
@ -140,83 +117,26 @@ export function SloEditForm({ slo }: Props) {
verticalAlign="top"
icon={
<EuiAvatar
color={
isIndicatorSectionValid
? euiThemeVars.euiColorSuccess
: euiThemeVars.euiColorPrimary
}
color={getIconColor(isIndicatorSectionValid)}
iconType={isIndicatorSectionValid ? 'check' : ''}
name={isIndicatorSectionValid ? 'Check' : '1'}
/>
}
>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="none" style={{ maxWidth }}>
<EuiTitle>
<h2>
{i18n.translate('xpack.observability.slo.sloEdit.definition.title', {
defaultMessage: 'Define SLI',
})}
</h2>
</EuiTitle>
<EuiSpacer size="xl" />
<EuiFormLabel>
{i18n.translate('xpack.observability.slo.sloEdit.definition.sliType', {
defaultMessage: 'SLI type',
})}
</EuiFormLabel>
<Controller
name="indicator.type"
control={control}
rules={{ required: true }}
render={({ field: { ref, ...field } }) => (
<EuiSelect
data-test-subj="sloFormIndicatorTypeSelect"
{...field}
options={SLI_OPTIONS}
/>
)}
/>
<EuiSpacer size="xxl" />
{getIndicatorTypeForm()}
<EuiSpacer size="m" />
</EuiPanel>
<SloEditFormIndicatorSection />
</EuiTimelineItem>
<EuiTimelineItem
icon={
<EuiAvatar
color={
isObjectiveSectionValid
? euiThemeVars.euiColorSuccess
: euiThemeVars.euiColorPrimary
}
color={getIconColor(isObjectiveSectionValid)}
iconType={isObjectiveSectionValid ? 'check' : ''}
name={isObjectiveSectionValid ? 'Check' : '2'}
/>
}
verticalAlign="top"
>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="none" style={{ maxWidth }}>
<EuiTitle>
<h2>
{i18n.translate('xpack.observability.slo.sloEdit.objectives.title', {
defaultMessage: 'Set objectives',
})}
</h2>
</EuiTitle>
<EuiSpacer size="xl" />
<SloEditFormObjectives />
<EuiSpacer size="xl" />
</EuiPanel>
<SloEditFormObjectiveSection />
</EuiTimelineItem>
<EuiTimelineItem
@ -225,61 +145,42 @@ export function SloEditForm({ slo }: Props) {
<EuiAvatar
name={isDescriptionSectionValid ? 'Check' : '3'}
iconType={isDescriptionSectionValid ? 'check' : ''}
color={
isDescriptionSectionValid
? euiThemeVars.euiColorSuccess
: euiThemeVars.euiColorPrimary
}
color={getIconColor(isDescriptionSectionValid)}
/>
}
>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="none" style={{ maxWidth }}>
<EuiTitle>
<h2>
{i18n.translate('xpack.observability.slo.sloEdit.description.title', {
defaultMessage: 'Describe SLO',
})}
</h2>
</EuiTitle>
<SloEditFormDescriptionSection />
<EuiSpacer size="xl" />
<EuiFlexGroup direction="row" gutterSize="s">
<EuiButton
color="primary"
data-test-subj="sloFormSubmitButton"
fill
disabled={!formState.isValid}
isLoading={isCreateSloLoading || isUpdateSloLoading}
onClick={handleSubmit}
>
{isEditMode
? i18n.translate('xpack.observability.slo.sloEdit.editSloButton', {
defaultMessage: 'Update SLO',
})
: i18n.translate('xpack.observability.slo.sloEdit.createSloButton', {
defaultMessage: 'Create SLO',
})}
</EuiButton>
<SloEditFormDescription />
<EuiSpacer size="xl" />
<EuiFlexGroup direction="row" gutterSize="s">
<EuiButton
color="primary"
data-test-subj="sloFormSubmitButton"
fill
disabled={!formState.isValid}
isLoading={isCreateSloLoading || isUpdateSloLoading}
onClick={handleSubmit}
>
{isEditMode
? i18n.translate('xpack.observability.slo.sloEdit.editSloButton', {
defaultMessage: 'Update SLO',
})
: i18n.translate('xpack.observability.slo.sloEdit.createSloButton', {
defaultMessage: 'Create SLO',
})}
</EuiButton>
<EuiButton
color="ghost"
data-test-subj="sloFormCancelButton"
fill
onClick={() => navigateToUrl(basePath.prepend(paths.observability.slos))}
>
{i18n.translate('xpack.observability.slo.sloEdit.cancelButton', {
defaultMessage: 'Cancel',
})}
</EuiButton>
</EuiFlexGroup>
<EuiSpacer size="xl" />
</EuiPanel>
<EuiButton
color="ghost"
data-test-subj="sloFormCancelButton"
fill
disabled={isCreateSloLoading || isUpdateSloLoading}
onClick={() => navigateToUrl(basePath.prepend(paths.observability.slos))}
>
{i18n.translate('xpack.observability.slo.sloEdit.cancelButton', {
defaultMessage: 'Cancel',
})}
</EuiButton>
</EuiFlexGroup>
</EuiTimelineItem>
</EuiTimeline>
</FormProvider>

View file

@ -1,152 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiComboBox,
EuiComboBoxOptionOption,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormLabel,
EuiTextArea,
useGeneratedHtmlId,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import type { CreateSLOInput } from '@kbn/slo-schema';
export function SloEditFormDescription() {
const { control } = useFormContext<CreateSLOInput>();
const sloNameId = useGeneratedHtmlId({ prefix: 'sloName' });
const descriptionId = useGeneratedHtmlId({ prefix: 'sloDescription' });
const tagsId = useGeneratedHtmlId({ prefix: 'tags' });
return (
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexItem>
<EuiFormLabel>
{i18n.translate('xpack.observability.slo.sloEdit.description.sloName', {
defaultMessage: 'SLO Name',
})}
</EuiFormLabel>
<Controller
name="name"
control={control}
rules={{ required: true }}
render={({ field: { ref, ...field } }) => (
<EuiFieldText
fullWidth
id={sloNameId}
data-test-subj="sloFormNameInput"
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.description.sloNamePlaceholder',
{
defaultMessage: 'Name for the SLO',
}
)}
{...field}
/>
)}
/>
</EuiFlexItem>
<EuiFlexItem grow>
<EuiFormLabel>
{i18n.translate('xpack.observability.slo.sloEdit.description.sloDescription', {
defaultMessage: 'Description',
})}
</EuiFormLabel>
<Controller
name="description"
defaultValue=""
control={control}
render={({ field: { ref, ...field } }) => (
<EuiTextArea
fullWidth
id={descriptionId}
data-test-subj="sloFormDescriptionTextArea"
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.description.sloDescriptionPlaceholder',
{
defaultMessage: 'A short description of the SLO',
}
)}
{...field}
/>
)}
/>
</EuiFlexItem>
<EuiFlexItem grow>
<EuiFormLabel>
{i18n.translate('xpack.observability.slo.sloEdit.tags.label', {
defaultMessage: 'Tags',
})}
</EuiFormLabel>
<Controller
shouldUnregister={true}
name="tags"
control={control}
defaultValue={[]}
rules={{ required: false }}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiComboBox
{...field}
id={tagsId}
fullWidth
aria-label={i18n.translate('xpack.observability.slo.sloEdit.tags.placeholder', {
defaultMessage: 'Add tags',
})}
placeholder={i18n.translate('xpack.observability.slo.sloEdit.tags.placeholder', {
defaultMessage: 'Add tags',
})}
isInvalid={!!fieldState.error}
options={[]}
noSuggestions
selectedOptions={generateTagOptions(field.value)}
onChange={(selected: EuiComboBoxOptionOption[]) => {
if (selected.length) {
return field.onChange(selected.map((opts) => opts.value));
}
field.onChange([]);
}}
onCreateOption={(searchValue: string, options: EuiComboBoxOptionOption[] = []) => {
const normalizedSearchValue = searchValue.trim().toLowerCase();
if (!normalizedSearchValue) {
return;
}
const values = field.value ?? [];
if (
values.findIndex((tag) => tag.trim().toLowerCase() === normalizedSearchValue) ===
-1
) {
field.onChange([...values, searchValue]);
}
}}
isClearable={true}
data-test-subj="sloEditApmAvailabilityGoodStatusCodesSelector"
/>
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
function generateTagOptions(tags: string[] = []) {
return tags.map((tag) => ({
label: tag,
value: tag,
'data-test-subj': `${tag}Option`,
}));
}

View file

@ -10,12 +10,12 @@ import { ComponentStory } from '@storybook/react';
import { FormProvider, useForm } from 'react-hook-form';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import { SloEditFormDescription as Component } from './slo_edit_form_description';
import { SloEditFormDescriptionSection as Component } from './slo_edit_form_description_section';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
export default {
component: Component,
title: 'app/SLO/EditPage/SloEditFormDescription',
title: 'app/SLO/EditPage/SloEditFormDescriptionSection',
decorators: [KibanaReactStorybookDecorator],
};

View file

@ -0,0 +1,174 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiComboBox,
EuiComboBoxOptionOption,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormLabel,
EuiPanel,
EuiSpacer,
EuiTextArea,
EuiTitle,
useGeneratedHtmlId,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import type { CreateSLOInput } from '@kbn/slo-schema';
import { maxWidth } from './slo_edit_form';
export function SloEditFormDescriptionSection() {
const { control } = useFormContext<CreateSLOInput>();
const sloNameId = useGeneratedHtmlId({ prefix: 'sloName' });
const descriptionId = useGeneratedHtmlId({ prefix: 'sloDescription' });
const tagsId = useGeneratedHtmlId({ prefix: 'tags' });
return (
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="none" style={{ maxWidth }}>
<EuiTitle>
<h2>
{i18n.translate('xpack.observability.slo.sloEdit.description.title', {
defaultMessage: 'Describe SLO',
})}
</h2>
</EuiTitle>
<EuiSpacer size="xl" />
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexItem>
<EuiFormLabel>
{i18n.translate('xpack.observability.slo.sloEdit.description.sloName', {
defaultMessage: 'SLO Name',
})}
</EuiFormLabel>
<Controller
name="name"
control={control}
rules={{ required: true }}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiFieldText
{...field}
fullWidth
isInvalid={fieldState.invalid}
id={sloNameId}
data-test-subj="sloFormNameInput"
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.description.sloNamePlaceholder',
{
defaultMessage: 'Name for the SLO',
}
)}
/>
)}
/>
</EuiFlexItem>
<EuiFlexItem grow>
<EuiFormLabel>
{i18n.translate('xpack.observability.slo.sloEdit.description.sloDescription', {
defaultMessage: 'Description',
})}
</EuiFormLabel>
<Controller
name="description"
defaultValue=""
control={control}
rules={{ required: false }}
render={({ field: { ref, ...field } }) => (
<EuiTextArea
{...field}
fullWidth
id={descriptionId}
data-test-subj="sloFormDescriptionTextArea"
placeholder={i18n.translate(
'xpack.observability.slo.sloEdit.description.sloDescriptionPlaceholder',
{
defaultMessage: 'A short description of the SLO',
}
)}
/>
)}
/>
</EuiFlexItem>
<EuiFlexItem grow>
<EuiFormLabel>
{i18n.translate('xpack.observability.slo.sloEdit.tags.label', {
defaultMessage: 'Tags',
})}
</EuiFormLabel>
<Controller
shouldUnregister={true}
name="tags"
control={control}
defaultValue={[]}
rules={{ required: false }}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiComboBox
{...field}
id={tagsId}
fullWidth
aria-label={i18n.translate('xpack.observability.slo.sloEdit.tags.placeholder', {
defaultMessage: 'Add tags',
})}
placeholder={i18n.translate('xpack.observability.slo.sloEdit.tags.placeholder', {
defaultMessage: 'Add tags',
})}
isInvalid={!!fieldState.error}
options={[]}
noSuggestions
selectedOptions={generateTagOptions(field.value)}
onChange={(selected: EuiComboBoxOptionOption[]) => {
if (selected.length) {
return field.onChange(selected.map((opts) => opts.value));
}
field.onChange([]);
}}
onCreateOption={(searchValue: string, options: EuiComboBoxOptionOption[] = []) => {
const normalizedSearchValue = searchValue.trim().toLowerCase();
if (!normalizedSearchValue) {
return;
}
const values = field.value ?? [];
if (
values.findIndex(
(tag) => tag.trim().toLowerCase() === normalizedSearchValue
) === -1
) {
field.onChange([...values, searchValue]);
}
}}
isClearable={true}
data-test-subj="sloEditApmAvailabilityGoodStatusCodesSelector"
/>
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xl" />
</EuiPanel>
);
}
function generateTagOptions(tags: string[] = []) {
return tags.map((tag) => ({
label: tag,
value: tag,
'data-test-subj': `${tag}Option`,
}));
}

View file

@ -0,0 +1,74 @@
/*
* 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 { EuiFormLabel, EuiPanel, EuiSelect, EuiSpacer, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { CreateSLOInput } from '@kbn/slo-schema';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { SLI_OPTIONS } from '../constants';
import { ApmAvailabilityIndicatorTypeForm } from './apm_availability/apm_availability_indicator_type_form';
import { ApmLatencyIndicatorTypeForm } from './apm_latency/apm_latency_indicator_type_form';
import { CustomKqlIndicatorTypeForm } from './custom_kql/custom_kql_indicator_type_form';
import { maxWidth } from './slo_edit_form';
export function SloEditFormIndicatorSection() {
const { control, watch } = useFormContext<CreateSLOInput>();
const getIndicatorTypeForm = () => {
switch (watch('indicator.type')) {
case 'sli.kql.custom':
return <CustomKqlIndicatorTypeForm />;
case 'sli.apm.transactionDuration':
return <ApmLatencyIndicatorTypeForm />;
case 'sli.apm.transactionErrorRate':
return <ApmAvailabilityIndicatorTypeForm />;
default:
return null;
}
};
return (
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="none" style={{ maxWidth }}>
<EuiTitle>
<h2>
{i18n.translate('xpack.observability.slo.sloEdit.definition.title', {
defaultMessage: 'Define SLI',
})}
</h2>
</EuiTitle>
<EuiSpacer size="xl" />
<EuiFormLabel>
{i18n.translate('xpack.observability.slo.sloEdit.definition.sliType', {
defaultMessage: 'Choose the SLI type',
})}
</EuiFormLabel>
<Controller
name="indicator.type"
control={control}
rules={{ required: true }}
render={({ field: { ref, ...field } }) => (
<EuiSelect
{...field}
required
data-test-subj="sloFormIndicatorTypeSelect"
options={SLI_OPTIONS}
/>
)}
/>
<EuiSpacer size="xxl" />
{getIndicatorTypeForm()}
<EuiSpacer size="m" />
</EuiPanel>
);
}

View file

@ -10,12 +10,12 @@ import { ComponentStory } from '@storybook/react';
import { FormProvider, useForm } from 'react-hook-form';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import { SloEditFormObjectives as Component } from './slo_edit_form_objectives';
import { SloEditFormObjectiveSection as Component } from './slo_edit_form_objective_section';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
export default {
component: Component,
title: 'app/SLO/EditPage/SloEditFormObjectives',
title: 'app/SLO/EditPage/SloEditFormObjectiveSection',
decorators: [KibanaReactStorybookDecorator],
};

View file

@ -11,24 +11,36 @@ import {
EuiFlexGrid,
EuiFlexItem,
EuiFormLabel,
EuiPanel,
EuiSelect,
EuiSpacer,
EuiTitle,
useGeneratedHtmlId,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Controller, useFormContext } from 'react-hook-form';
import type { CreateSLOInput } from '@kbn/slo-schema';
import { SloEditFormObjectivesTimeslices } from './slo_edit_form_objectives_timeslices';
import { SloEditFormObjectiveSectionTimeslices } from './slo_edit_form_objective_section_timeslices';
import { BUDGETING_METHOD_OPTIONS, TIMEWINDOW_OPTIONS } from '../constants';
import { maxWidth } from './slo_edit_form';
export function SloEditFormObjectives() {
export function SloEditFormObjectiveSection() {
const { control, watch } = useFormContext<CreateSLOInput>();
const budgetingSelect = useGeneratedHtmlId({ prefix: 'budgetingSelect' });
const timeWindowSelect = useGeneratedHtmlId({ prefix: 'timeWindowSelect' });
return (
<>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="none" style={{ maxWidth }}>
<EuiTitle>
<h2>
{i18n.translate('xpack.observability.slo.sloEdit.objectives.title', {
defaultMessage: 'Set objectives',
})}
</h2>
</EuiTitle>
<EuiSpacer size="xl" />
<EuiFlexGrid columns={3}>
<EuiFlexItem>
<EuiFormLabel>
@ -43,10 +55,11 @@ export function SloEditFormObjectives() {
rules={{ required: true }}
render={({ field: { ref, ...field } }) => (
<EuiSelect
{...field}
required
id={budgetingSelect}
data-test-subj="sloFormBudgetingMethodSelect"
options={BUDGETING_METHOD_OPTIONS}
{...field}
/>
)}
/>
@ -65,10 +78,11 @@ export function SloEditFormObjectives() {
rules={{ required: true }}
render={({ field: { ref, ...field } }) => (
<EuiSelect
{...field}
required
id={timeWindowSelect}
data-test-subj="sloFormTimeWindowDurationSelect"
options={TIMEWINDOW_OPTIONS}
{...field}
value={String(field.value)}
/>
)}
@ -90,10 +104,12 @@ export function SloEditFormObjectives() {
min: 0.001,
max: 99.999,
}}
render={({ field: { ref, ...field } }) => (
render={({ field: { ref, ...field }, fieldState }) => (
<EuiFieldNumber
data-test-subj="sloFormObjectiveTargetInput"
{...field}
required
isInvalid={fieldState.invalid}
data-test-subj="sloFormObjectiveTargetInput"
value={String(field.value)}
min={0.001}
max={99.999}
@ -108,9 +124,10 @@ export function SloEditFormObjectives() {
{watch('budgetingMethod') === 'timeslices' ? (
<>
<EuiSpacer size="xl" />
<SloEditFormObjectivesTimeslices />
<SloEditFormObjectiveSectionTimeslices />
</>
) : null}
</>
<EuiSpacer size="xl" />
</EuiPanel>
);
}

View file

@ -10,12 +10,12 @@ import { ComponentStory } from '@storybook/react';
import { FormProvider, useForm } from 'react-hook-form';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import { SloEditFormObjectivesTimeslices as Component } from './slo_edit_form_objectives_timeslices';
import { SloEditFormObjectiveSectionTimeslices as Component } from './slo_edit_form_objective_section_timeslices';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
export default {
component: Component,
title: 'app/SLO/EditPage/SloEditFormObjectivesTimeslices',
title: 'app/SLO/EditPage/SloEditFormObjectiveSectionTimeslices',
decorators: [KibanaReactStorybookDecorator],
};

View file

@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n';
import { Controller, useFormContext } from 'react-hook-form';
import type { CreateSLOInput } from '@kbn/slo-schema';
export function SloEditFormObjectivesTimeslices() {
export function SloEditFormObjectiveSectionTimeslices() {
const { control } = useFormContext<CreateSLOInput>();
return (
<EuiFlexGrid columns={3}>
@ -31,9 +31,11 @@ export function SloEditFormObjectivesTimeslices() {
min: 0.001,
max: 99.999,
}}
render={({ field: { ref, ...field } }) => (
render={({ field: { ref, ...field }, fieldState }) => (
<EuiFieldNumber
{...field}
required
isInvalid={fieldState.invalid}
value={String(field.value)}
data-test-subj="sloFormObjectiveTimesliceTargetInput"
min={0.001}
@ -58,9 +60,11 @@ export function SloEditFormObjectivesTimeslices() {
defaultValue="1"
control={control}
rules={{ required: true, min: 1, max: 120 }}
render={({ field: { ref, ...field } }) => (
render={({ field: { ref, ...field }, fieldState }) => (
<EuiFieldNumber
{...field}
isInvalid={fieldState.invalid}
required
data-test-subj="sloFormObjectiveTimesliceWindowInput"
value={String(field.value)}
min={1}

View file

@ -415,6 +415,7 @@ describe('SLO Edit Page', () => {
"filter": "baz: foo and bar > 2",
"good": "http_status: 2xx",
"index": "some-index",
"timestampField": "custom_timestamp",
"total": "a query",
},
"type": "sli.kql.custom",
@ -426,7 +427,6 @@ describe('SLO Edit Page', () => {
"settings": Object {
"frequency": "1m",
"syncDelay": "1m",
"timestampField": "@timestamp",
},
"tags": Array [
"k8s",

View file

@ -41,7 +41,6 @@ describe('validateSLO', () => {
const slo = createSLO({
settings: {
frequency: sixHours(),
timestampField: '@timestamp',
syncDelay: oneMinute(),
},
});
@ -52,7 +51,6 @@ describe('validateSLO', () => {
const slo = createSLO({
settings: {
frequency: oneMinute(),
timestampField: '@timestamp',
syncDelay: sixHours(),
},
});

View file

@ -49,7 +49,6 @@ export const slo: SavedObjectsType = {
},
settings: {
properties: {
timestampField: { type: 'keyword' },
syncDelay: { type: 'keyword' },
frequency: { type: 'keyword' },
},

View file

@ -43,7 +43,6 @@ describe('CreateSLO', () => {
...sloParams,
id: expect.any(String),
settings: {
timestampField: '@timestamp',
syncDelay: oneMinute(),
frequency: oneMinute(),
},
@ -66,7 +65,6 @@ describe('CreateSLO', () => {
indicator: createAPMTransactionErrorRateIndicator(),
tags: ['one', 'two'],
settings: {
timestampField: '@timestamp2',
syncDelay: fiveMinute(),
},
});
@ -80,7 +78,6 @@ describe('CreateSLO', () => {
...sloParams,
id: expect.any(String),
settings: {
timestampField: '@timestamp2',
syncDelay: fiveMinute(),
frequency: oneMinute(),
},

View file

@ -57,7 +57,6 @@ export class CreateSLO {
...params,
id: uuidv1(),
settings: {
timestampField: params.settings?.timestampField ?? '@timestamp',
syncDelay: params.settings?.syncDelay ?? new Duration(1, DurationUnit.Minute),
frequency: params.settings?.frequency ?? new Duration(1, DurationUnit.Minute),
},

View file

@ -66,7 +66,6 @@ describe('FindSLO', () => {
isRolling: true,
},
settings: {
timestampField: '@timestamp',
syncDelay: '1m',
frequency: '1m',
},

View file

@ -78,7 +78,6 @@ const defaultSLO: Omit<SLO, 'id' | 'revision' | 'createdAt' | 'updatedAt'> = {
},
indicator: createAPMTransactionDurationIndicator(),
settings: {
timestampField: '@timestamp',
syncDelay: new Duration(1, DurationUnit.Minute),
frequency: new Duration(1, DurationUnit.Minute),
},

View file

@ -59,9 +59,13 @@ export async function getSloDiagnosis(
let dataSample;
if (sloSavedObject?.attributes.indicator.params.index) {
const slo = sloSavedObject.attributes;
const sortField =
'timestampField' in slo.indicator.params
? slo.indicator.params.timestampField ?? '@timestamp'
: '@timestamp';
dataSample = await esClient.search({
index: slo.indicator.params.index,
sort: { [slo.settings.timestampField]: 'desc' },
sort: { [sortField]: 'desc' },
size: 5,
});
}

View file

@ -66,7 +66,6 @@ describe('GetSLO', () => {
isRolling: true,
},
settings: {
timestampField: '@timestamp',
syncDelay: '1m',
frequency: '1m',
},

View file

@ -34,7 +34,7 @@ export class ApmTransactionDurationTransformGenerator extends TransformGenerator
this.buildDescription(slo),
this.buildSource(slo, slo.indicator),
this.buildDestination(),
this.buildCommonGroupBy(slo),
this.buildGroupBy(slo),
this.buildAggregations(slo, slo.indicator),
this.buildSettings(slo)
);
@ -48,7 +48,7 @@ export class ApmTransactionDurationTransformGenerator extends TransformGenerator
const queryFilter: Query[] = [
{
range: {
[slo.settings.timestampField]: {
'@timestamp': {
gte: `now-${slo.timeWindow.duration.format()}`,
},
},

View file

@ -38,7 +38,7 @@ export class ApmTransactionErrorRateTransformGenerator extends TransformGenerato
this.buildDescription(slo),
this.buildSource(slo, slo.indicator),
this.buildDestination(),
this.buildCommonGroupBy(slo),
this.buildGroupBy(slo),
this.buildAggregations(slo, slo.indicator),
this.buildSettings(slo)
);
@ -52,7 +52,7 @@ export class ApmTransactionErrorRateTransformGenerator extends TransformGenerato
const queryFilter: Query[] = [
{
range: {
[slo.settings.timestampField]: {
'@timestamp': {
gte: `now-${slo.timeWindow.duration.format()}`,
},
},

View file

@ -83,6 +83,19 @@ describe('KQL Custom Transform Generator', () => {
expect(transform.source.index).toBe('my-own-index*');
});
it('uses the provided timestampField', async () => {
const anSLO = createSLO({
indicator: createKQLCustomIndicator({
timestampField: 'my-date-field',
}),
});
const transform = generator.getTransformParams(anSLO);
expect(transform.sync?.time?.field).toBe('my-date-field');
// @ts-ignore
expect(transform.pivot?.group_by['@timestamp'].date_histogram.field).toBe('my-date-field');
});
it('aggregates using the numerator kql', async () => {
const anSLO = createSLO({
indicator: createKQLCustomIndicator({

View file

@ -29,9 +29,9 @@ export class KQLCustomTransformGenerator extends TransformGenerator {
this.buildDescription(slo),
this.buildSource(slo, slo.indicator),
this.buildDestination(),
this.buildCommonGroupBy(slo),
this.buildGroupBy(slo, this.getTimestampFieldOrDefault(slo.indicator.params.timestampField)),
this.buildAggregations(slo, slo.indicator),
this.buildSettings(slo)
this.buildSettings(slo, this.getTimestampFieldOrDefault(slo.indicator.params.timestampField))
);
}
@ -79,4 +79,8 @@ export class KQLCustomTransformGenerator extends TransformGenerator {
}),
};
}
private getTimestampFieldOrDefault(timestampField: string | undefined): string {
return !!timestampField && timestampField.length > 0 ? timestampField : '@timestamp';
}
}

View file

@ -36,7 +36,7 @@ export abstract class TransformGenerator {
return `Rolled-up SLI data for SLO: ${slo.name}`;
}
public buildCommonGroupBy(slo: SLO) {
public buildGroupBy(slo: SLO, sourceIndexTimestampField: string | undefined = '@timestamp') {
let fixedInterval = '1m';
if (timeslicesBudgetingMethodSchema.is(slo.budgetingMethod)) {
fixedInterval = slo.objective.timesliceWindow!.format();
@ -53,20 +53,23 @@ export abstract class TransformGenerator {
field: 'slo.revision',
},
},
// Field used in the destination index, using @timestamp as per mapping definition
// timestamp field defined in the destination index
'@timestamp': {
date_histogram: {
field: slo.settings.timestampField,
field: sourceIndexTimestampField, // timestamp field defined in the source index
fixed_interval: fixedInterval,
},
},
};
}
public buildSettings(slo: SLO): TransformSettings {
public buildSettings(
slo: SLO,
sourceIndexTimestampField: string | undefined = '@timestamp'
): TransformSettings {
return {
frequency: slo.settings.frequency.format(),
sync_field: slo.settings.timestampField,
sync_field: sourceIndexTimestampField, // timestamp field defined in the source index
sync_delay: slo.settings.syncDelay.format(),
};
}