[SLO] allow multi group by in creation form (#175063)

## Summary

Enables grouping SLOs by multiple fields.

Resolves https://github.com/elastic/kibana/issues/174228

Adjusts the `instanceId`
[implementation](https://github.com/elastic/kibana/pull/175063/files#diff-c9c4989cf8d323448464b70825408b535b1fa6cc355df26a2acfba45d8c05232R35)
to concat values from each field to build the instance id.

Uses the `groupings` key from the
[summary](https://github.com/elastic/kibana/pull/175063/files#diff-40b886fca239a397d0990f3db135f7b35822ee0aa93063be219bd23cafc9be6cR137)
[documents](https://github.com/elastic/kibana/pull/175063/files#diff-d5cd07fb8fb91091a7f65d9f59c268600f03305167b375ec579773144428ee68R156)
to display group by values in the SLO list and SLO detail pages.

### Testing
1. Before checking out this PR, create an SLO on main with a group by
2. Check out this PR. The instance information should continue to
populate, now with the field label
<img width="364" alt="Screenshot 2024-02-05 at 9 50 12 PM"
src="d6004cc6-58b8-4319-b28b-b09e7849deba">
<img width="1427" alt="Screenshot 2024-02-05 at 9 50 26 PM"
src="10aacc47-ae68-489d-b728-6f74816e7c69">
<img width="583" alt="Screenshot 2024-02-05 at 9 50 38 PM"
src="50bcc168-bdb7-421b-8a6f-9fd0b079b612">
3. Navigate to the edit flow. The group by field should appear in the
ComboBox.
<img width="803" alt="Screenshot 2024-02-05 at 9 56 28 PM"
src="c9601e61-85b9-49ad-adc0-0ebfad67701e">
4. Attempt to create a second group by
<img width="815" alt="Screenshot 2024-02-05 at 10 57 38 PM"
src="e461681c-720f-4290-a01c-0cd00c9cbb72">
5. Save and observe the created instances.
<img width="716" alt="Screenshot 2024-02-06 at 10 39 56 AM"
src="46599aaa-ac93-4362-943a-4579b2e8c552">
<img width="590" alt="Screenshot 2024-02-06 at 11 24 52 AM"
src="ef9d65fb-301c-446d-a748-e82d8460423c">
<img width="666" alt="Screenshot 2024-02-06 at 11 25 08 AM"
src="6713ba80-6a2a-4087-9335-cfa935bf0ebf">
<img width="1449" alt="Screenshot 2024-02-06 at 11 25 20 AM"
src="c7088ab3-847f-436e-8918-2a5f15593ac4">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Dominique Clarke 2024-02-26 12:46:57 -05:00 committed by GitHub
parent 8122a3ef64
commit 882682b6bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 909 additions and 310 deletions

View file

@ -14,6 +14,7 @@ import {
budgetingMethodSchema,
dateType,
durationType,
groupingsSchema,
histogramIndicatorSchema,
historicalSummarySchema,
indicatorSchema,
@ -34,6 +35,7 @@ import {
timesliceMetricBasicMetricWithField,
timesliceMetricDocCountMetric,
timesliceMetricPercentileMetric,
allOrAnyStringOrArray,
kqlWithFiltersSchema,
querySchema,
} from '../schema';
@ -52,7 +54,7 @@ const createSLOParamsSchema = t.type({
id: sloIdSchema,
settings: optionalSettingsSchema,
tags: tagsSchema,
groupBy: allOrAnyString,
groupBy: allOrAnyStringOrArray,
revision: t.number,
}),
]),
@ -75,6 +77,7 @@ const getPreviewDataParamsSchema = t.type({
objective: objectiveSchema,
instanceId: t.string,
groupBy: t.string,
groupings: t.record(t.string, t.unknown),
}),
]),
});
@ -136,7 +139,7 @@ const sloResponseSchema = t.intersection([
settings: settingsSchema,
enabled: t.boolean,
tags: tagsSchema,
groupBy: allOrAnyString,
groupBy: allOrAnyStringOrArray,
createdAt: dateType,
updatedAt: dateType,
version: t.number,
@ -148,7 +151,7 @@ const sloResponseSchema = t.intersection([
const sloWithSummaryResponseSchema = t.intersection([
sloResponseSchema,
t.type({ summary: summarySchema }),
t.type({ summary: summarySchema, groupings: groupingsSchema }),
]);
const sloGroupWithSummaryResponseSchema = t.type({
@ -186,7 +189,7 @@ const updateSLOParamsSchema = t.type({
objective: objectiveSchema,
settings: optionalSettingsSchema,
tags: tagsSchema,
groupBy: allOrAnyString,
groupBy: allOrAnyStringOrArray,
}),
});
@ -221,7 +224,9 @@ const deleteSLOInstancesParamsSchema = t.type({
});
const fetchHistoricalSummaryParamsSchema = t.type({
body: t.type({ list: t.array(t.type({ sloId: sloIdSchema, instanceId: allOrAnyString })) }),
body: t.type({
list: t.array(t.type({ sloId: sloIdSchema, instanceId: t.string })),
}),
});
const fetchHistoricalSummaryResponseSchema = t.array(
@ -276,7 +281,7 @@ const getSLOInstancesParamsSchema = t.type({
});
const getSLOInstancesResponseSchema = t.type({
groupBy: t.string,
groupBy: t.union([t.string, t.array(t.string)]),
instances: t.array(t.string),
});

View file

@ -12,6 +12,12 @@ const ALL_VALUE = '*';
const allOrAnyString = t.union([t.literal(ALL_VALUE), t.string]);
const allOrAnyStringOrArray = t.union([
t.literal(ALL_VALUE),
t.string,
t.array(t.union([t.literal(ALL_VALUE), t.string])),
]);
const dateType = new t.Type<Date, string, unknown>(
'DateType',
(input: unknown): input is Date => input instanceof Date,
@ -43,6 +49,8 @@ const summarySchema = t.type({
errorBudget: errorBudgetSchema,
});
const groupingsSchema = t.record(t.string, t.union([t.string, t.number]));
const groupSummarySchema = t.type({
total: t.number,
worst: t.type({
@ -115,9 +123,11 @@ const querySchema = t.union([kqlQuerySchema, kqlWithFiltersSchema]);
export {
ALL_VALUE,
allOrAnyString,
allOrAnyStringOrArray,
dateRangeSchema,
dateType,
errorBudgetSchema,
groupingsSchema,
historicalSummarySchema,
previewDataSchema,
statusSchema,

View file

@ -6,7 +6,7 @@
*/
import * as t from 'io-ts';
import { allOrAnyString, dateType, summarySchema } from './common';
import { allOrAnyStringOrArray, dateType, summarySchema, groupingsSchema } from './common';
import { durationType } from './duration';
import { indicatorSchema } from './indicators';
import { timeWindowSchema } from './time_window';
@ -49,11 +49,14 @@ const sloSchema = t.type({
tags: tagsSchema,
createdAt: dateType,
updatedAt: dateType,
groupBy: allOrAnyString,
groupBy: allOrAnyStringOrArray,
version: t.number,
});
const sloWithSummarySchema = t.intersection([sloSchema, t.type({ summary: summarySchema })]);
const sloWithSummarySchema = t.intersection([
sloSchema,
t.type({ summary: summarySchema, groupings: groupingsSchema }),
]);
export {
budgetingMethodSchema,

View file

@ -18,6 +18,7 @@ import { ValidationBurnRateRuleResult } from './validation';
import { createNewWindow, Windows } from './windows';
import { BURN_RATE_DEFAULTS } from './constants';
import { AlertTimeTable } from './alert_time_table';
import { getGroupKeysProse } from '../../utils/slo/groupings';
type Props = Pick<
RuleTypeParamsExpressionProps<BurnRateRuleParams>,
@ -67,7 +68,7 @@ export function BurnRateRuleEditor(props: Props) {
</EuiTitle>
<EuiSpacer size="s" />
<SloSelector initialSlo={selectedSlo} onSelected={onSelectedSlo} errors={errors.sloId} />
{selectedSlo?.groupBy && selectedSlo.groupBy !== ALL_VALUE && (
{selectedSlo?.groupBy && ![selectedSlo.groupBy].flat().includes(ALL_VALUE) && (
<>
<EuiSpacer size="l" />
<EuiCallOut
@ -75,8 +76,8 @@ export function BurnRateRuleEditor(props: Props) {
size="s"
title={i18n.translate('xpack.observability.slo.rules.groupByMessage', {
defaultMessage:
'The SLO you selected has been created with a group-by on "{groupByField}". This rule will monitor and generate an alert for every instance found in the group-by field.',
values: { groupByField: selectedSlo.groupBy },
'The SLO you selected has been created with a group-by on {groupByField}. This rule will monitor and generate an alert for every instance found in the group-by field.',
values: { groupByField: getGroupKeysProse(selectedSlo.groupBy) },
})}
/>
</>

View file

@ -9,6 +9,7 @@ import { EuiConfirmModal } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ALL_VALUE, SLOResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import React from 'react';
import { getGroupKeysProse } from '../../../utils/slo/groupings';
export interface SloDeleteConfirmationModalProps {
slo: SLOWithSummaryResponse | SLOResponse;
@ -44,8 +45,8 @@ export function SloDeleteConfirmationModal({
{groupBy !== ALL_VALUE
? i18n.translate('xpack.observability.slo.deleteConfirmationModal.groupByDisclaimerText', {
defaultMessage:
'This SLO has been generated with a group key on "{groupKey}". Deleting this SLO definition will result in all instances being deleted.',
values: { groupKey: groupBy },
'This SLO has been generated with a group key on {groupKey}. Deleting this SLO definition will result in all instances being deleted.',
values: { groupKey: getGroupKeysProse(slo.groupBy) },
})
: i18n.translate('xpack.observability.slo.deleteConfirmationModal.descriptionText', {
defaultMessage: "You can't recover this SLO after deleting it.",

View file

@ -1,44 +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 { EuiBadge, EuiBadgeProps, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema';
import React from 'react';
import { euiLightVars } from '@kbn/ui-theme';
export interface Props {
color?: EuiBadgeProps['color'];
slo: SLOWithSummaryResponse;
}
export function SloGroupByBadge({ slo, color }: Props) {
if (!slo.groupBy || slo.groupBy === ALL_VALUE) {
return null;
}
return (
<EuiFlexItem grow={false}>
<EuiBadge color={color ?? euiLightVars.euiColorDisabled}>
<EuiToolTip
position="top"
content={i18n.translate('xpack.observability.slo.groupByBadge', {
defaultMessage: 'Group by {groupKey}',
values: {
groupKey: slo.groupBy,
},
})}
display="block"
>
<span>
{slo.groupBy}: {slo.instanceId}
</span>
</EuiToolTip>
</EuiBadge>
</EuiFlexItem>
);
}

View file

@ -63,6 +63,7 @@ const baseSlo: Omit<SLOWithSummaryResponse, 'id'> = {
},
},
groupBy: ALL_VALUE,
groupings: {},
instanceId: ALL_VALUE,
tags: ['k8s', 'production', 'critical'],
enabled: true,

View file

@ -27,8 +27,7 @@ const SLO_REQUIRED = i18n.translate('xpack.observability.sloEmbeddable.config.er
export function SloSelector({ initialSlos, onSelected, hasError, singleSelection }: Props) {
const mapSlosToOptions = (slos: SloItem[] | SLOWithSummaryResponse[] | undefined) =>
slos?.map((slo) => ({
label:
slo.instanceId !== ALL_VALUE ? `${slo.name} (${slo.groupBy}: ${slo.instanceId})` : slo.name,
label: slo.instanceId !== ALL_VALUE ? `${slo.name} (${slo.instanceId})` : slo.name,
value: `${slo.id}-${slo.instanceId}`,
})) ?? [];
const [options, setOptions] = useState<Array<EuiComboBoxOptionOption<string>>>([]);

View file

@ -26,6 +26,7 @@ import {
SloDetails,
SloTabId,
} from '../../../pages/slo_details/components/slo_details';
import { SLOGroupings } from '../../../pages/slos/components/common/slo_groupings';
export function SloOverviewDetails({
slo,
@ -65,6 +66,7 @@ export function SloOverviewDetails({
})}
</h2>
</EuiTitle>
<SLOGroupings slo={slo} />
</EuiFlyoutHeader>
<EuiFlyoutBody>
<HeaderTitle slo={slo} isLoading={false} showTitle={false} />

View file

@ -7,7 +7,7 @@
import React, { useEffect, useState, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingChart, EuiLink, EuiTitle } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingChart, EuiLink } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema';
@ -19,6 +19,7 @@ import { ErrorBudgetChart } from '../../../pages/slo_details/components/error_bu
import { EmbeddableSloProps } from './types';
import { SloOverviewDetails } from '../common/slo_overview_details'; // TODO change to slo_details
import { ErrorBudgetHeader } from '../../../pages/slo_details/components/error_budget_header';
import { SLOGroupings } from '../../../pages/slos/components/common/slo_groupings';
export function SloErrorBudget({
sloId,
@ -106,33 +107,20 @@ export function SloErrorBudget({
<div ref={containerRef} style={{ width: '100%', padding: 10 }}>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem>
{hasGroupBy ? (
<EuiTitle size="xs">
<h4>{slo.name}</h4>
</EuiTitle>
) : (
<EuiLink
css={{ fontSize: '16px' }}
data-test-subj="o11ySloErrorBudgetLink"
onClick={() => {
setSelectedSlo(slo);
}}
>
<h4>{slo.name}</h4>
</EuiLink>
)}
<EuiLink
css={{ fontSize: '16px' }}
data-test-subj="o11ySloErrorBudgetLink"
onClick={() => {
setSelectedSlo(slo);
}}
>
<h4>{slo.name}</h4>
</EuiLink>
</EuiFlexItem>
{hasGroupBy && (
<EuiFlexItem grow={false}>
<EuiLink
data-test-subj="o11ySloErrorBudgetLink"
onClick={() => {
setSelectedSlo(slo);
}}
>
{slo.groupBy}: {slo.instanceId}
</EuiLink>
<SLOGroupings slo={slo} />
</EuiFlexItem>
)}
</EuiFlexGroup>

View file

@ -47,8 +47,11 @@ export const sloKeys = {
globalDiagnosis: () => [...sloKeys.all, 'globalDiagnosis'] as const,
burnRates: (sloId: string, instanceId: string | undefined) =>
[...sloKeys.all, 'burnRates', sloId, instanceId] as const,
preview: (indicator: Indicator, range: { start: number; end: number }) =>
[...sloKeys.all, 'preview', indicator, range] as const,
preview: (
indicator: Indicator,
range: { start: number; end: number },
groupings?: Record<string, unknown>
) => [...sloKeys.all, 'preview', indicator, range, groupings] as const,
};
export type SloKeys = typeof sloKeys;

View file

@ -19,10 +19,18 @@ export interface UseFetchIndexPatternFieldsResponse {
const HIGH_CARDINALITY_THRESHOLD = 1000;
const buildInstanceId = (groupBy: string | string[]): string => {
const groups = [groupBy].flat().filter((value) => !!value);
const groupings = groups.map((group) => `'${group}:'+doc['${group}'].value`).join(`+'|'+`);
const hasAllGroupings = groups.map((group) => `doc['${group}'].size() > 0`).join(' && ');
return `if (${hasAllGroupings}) { emit(${groupings}) }`;
};
export function useFetchGroupByCardinality(
indexPattern: string,
timestampField: string,
groupBy: string
groupBy: string | string[]
): UseFetchIndexPatternFieldsResponse {
const { data: dataService } = useKibana().services;
@ -40,10 +48,16 @@ export function useFetchGroupByCardinality(
filter: [{ range: { [timestampField]: { gte: 'now-24h' } } }],
},
},
runtime_mappings: {
group_combinations: {
type: 'keyword',
script: buildInstanceId(groupBy),
},
},
aggs: {
groupByCardinality: {
cardinality: {
field: groupBy,
field: 'group_combinations',
},
},
},

View file

@ -24,11 +24,13 @@ export function useGetPreviewData({
indicator,
objective,
groupBy,
groupings,
instanceId,
}: {
isValid: boolean;
groupBy?: string;
instanceId?: string;
groupings?: Record<string, unknown>;
objective?: Objective;
indicator: Indicator;
range: { start: number; end: number };
@ -36,7 +38,7 @@ export function useGetPreviewData({
const { http } = useKibana().services;
const { isInitialLoading, isLoading, isError, isSuccess, data } = useQuery({
queryKey: sloKeys.preview(indicator, range),
queryKey: sloKeys.preview(indicator, range, groupings),
queryFn: async ({ signal }) => {
const response = await http.post<GetPreviewDataResponse>(
'/internal/observability/slos/_preview',
@ -46,6 +48,7 @@ export function useGetPreviewData({
range,
groupBy,
instanceId,
groupings,
...(objective ? { objective } : null),
}),
signal,

View file

@ -20,7 +20,7 @@ describe('SloEditLocator', () => {
it('should return correct url when slo is provided', async () => {
const location = await locator.getLocation(buildSlo({ id: 'foo' }));
expect(location.path).toEqual(
"/slos/edit/foo?_a=(budgetingMethod:occurrences,createdAt:'2022-12-29T10:11:12.000Z',description:'some%20description%20useful',enabled:!t,groupBy:'*',id:foo,indicator:(params:(filter:'baz:%20foo%20and%20bar%20%3E%202',good:'http_status:%202xx',index:some-index,timestampField:custom_timestamp,total:'a%20query'),type:sli.kql.custom),instanceId:'*',name:'super%20important%20level%20service',objective:(target:0.98),revision:1,settings:(frequency:'1m',syncDelay:'1m'),summary:(errorBudget:(consumed:0.064,initial:0.02,isEstimated:!f,remaining:0.936),sliValue:0.99872,status:HEALTHY),tags:!(k8s,production,critical),timeWindow:(duration:'30d',type:rolling),updatedAt:'2022-12-29T10:11:12.000Z',version:2)"
"/slos/edit/foo?_a=(budgetingMethod:occurrences,createdAt:'2022-12-29T10:11:12.000Z',description:'some%20description%20useful',enabled:!t,groupBy:'*',groupings:(),id:foo,indicator:(params:(filter:'baz:%20foo%20and%20bar%20%3E%202',good:'http_status:%202xx',index:some-index,timestampField:custom_timestamp,total:'a%20query'),type:sli.kql.custom),instanceId:'*',name:'super%20important%20level%20service',objective:(target:0.98),revision:1,settings:(frequency:'1m',syncDelay:'1m'),summary:(errorBudget:(consumed:0.064,initial:0.02,isEstimated:!f,remaining:0.936),sliValue:0.99872,status:HEALTHY),tags:!(k8s,production,critical),timeWindow:(duration:'30d',type:rolling),updatedAt:'2022-12-29T10:11:12.000Z',version:2)"
);
});
});

View file

@ -4,7 +4,6 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
AnnotationDomainType,
AreaSeries,
@ -61,7 +60,7 @@ export function EventsChartPanel({ slo, range }: Props) {
range,
isValid: true,
indicator: slo.indicator,
groupBy: slo.groupBy,
groupings: slo.groupings,
instanceId: slo.instanceId,
});

View file

@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import moment from 'moment';
import React from 'react';
import { SloGroupByBadge } from '../../../components/slo/slo_status_badge/slo_group_by_badge';
import { SLOGroupings } from '../../slos/components/common/slo_groupings';
import { SloStatusBadge } from '../../../components/slo/slo_status_badge';
export interface Props {
@ -32,22 +32,25 @@ export function HeaderTitle(props: Props) {
return (
<>
<EuiFlexGroup gutterSize="s">
<EuiFlexGroup direction="column" gutterSize="s">
{showTitle && <EuiFlexItem grow={false}>{slo.name}</EuiFlexItem>}
<EuiFlexGroup
direction="row"
gutterSize="s"
alignItems="center"
justifyContent="flexStart"
responsive={false}
>
<SloStatusBadge slo={slo} />
<SloGroupByBadge slo={slo} />
</EuiFlexGroup>
<EuiFlexGroup direction="column" gutterSize="xs">
{showTitle && (
<>
<EuiFlexItem grow={false}>{slo.name}</EuiFlexItem>
<SLOGroupings slo={slo} />
</>
)}
<EuiFlexGroup
direction="row"
gutterSize="s"
alignItems="center"
justifyContent="flexStart"
responsive={false}
>
<SloStatusBadge slo={slo} />
</EuiFlexGroup>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiSpacer size="xs" />
<EuiFlexGroup gutterSize="m">
<EuiFlexItem grow={false}>
<EuiText color="subdued" size="xs">

View file

@ -15,6 +15,7 @@ import { OptionalText } from './optional_text';
import { useFetchGroupByCardinality } from '../../../../hooks/slo/use_fetch_group_by_cardinality';
import { CreateSLOForm } from '../../types';
import { IndexFieldSelector } from './index_field_selector';
import { getGroupKeysProse } from '../../../../utils/slo/groupings';
export function GroupByField({ dataView, isLoading }: { dataView?: DataView; isLoading: boolean }) {
const { watch } = useFormContext<CreateSLOForm>();
@ -60,8 +61,11 @@ export function GroupByField({ dataView, isLoading }: { dataView?: DataView; isL
color={groupByCardinality.isHighCardinality ? 'warning' : 'primary'}
title={i18n.translate('xpack.observability.slo.sloEdit.groupBy.cardinalityInfo', {
defaultMessage:
"Selected group by field '{groupBy}' will generate at least {card} SLO instances based on the last 24h sample data.",
values: { card: groupByCardinality.cardinality, groupBy: groupByField },
'Selected group by field {groupBy} will generate at least {card} SLO instances based on the last 24h sample data.',
values: {
card: groupByCardinality.cardinality,
groupBy: getGroupKeysProse(groupByField),
},
})}
/>
)}

View file

@ -41,47 +41,55 @@ export function IndexFieldSelector({
setOptions(createOptionsFromFields(indexFields));
}, [indexFields]);
const getSelectedItems = (value: string | string[], fields: FieldSpec[]) => {
const values = [value].flat();
const selectedItems: Array<EuiComboBoxOptionOption<string>> = [];
fields.forEach((field) => {
if (values.includes(field.name)) {
selectedItems.push({ value: field.name, label: field.name });
}
});
return selectedItems;
};
return (
<EuiFlexItem>
<EuiFormRow label={label} isInvalid={getFieldState(name).invalid} labelAppend={labelAppend}>
<Controller
defaultValue={defaultValue}
defaultValue={[defaultValue].flat()}
name={name}
control={control}
rules={{ required: isRequired && !isDisabled }}
render={({ field, fieldState }) => (
<EuiComboBox<string>
{...field}
async
placeholder={placeholder}
aria-label={placeholder}
isClearable
isDisabled={isLoading || isDisabled}
isInvalid={fieldState.invalid}
isLoading={isLoading}
onChange={(selected: EuiComboBoxOptionOption[]) => {
if (selected.length) {
return field.onChange(selected[0].value);
}
render={({ field, fieldState }) => {
return (
<EuiComboBox<string>
{...field}
async
placeholder={placeholder}
aria-label={placeholder}
isClearable
isDisabled={isLoading || isDisabled}
isInvalid={fieldState.invalid}
isLoading={isLoading}
onChange={(selected: EuiComboBoxOptionOption[]) => {
if (selected.length) {
return field.onChange(selected.map((selection) => selection.value));
}
field.onChange(defaultValue);
}}
options={options}
onSearchChange={(searchValue: string) => {
setOptions(
createOptionsFromFields(indexFields, ({ value }) => value.includes(searchValue))
);
}}
selectedOptions={
!!indexFields &&
!!field.value &&
indexFields.some((indexField) => indexField.name === field.value)
? [{ value: field.value, label: field.value }]
: []
}
singleSelection
/>
)}
field.onChange(defaultValue);
}}
options={options}
onSearchChange={(searchValue: string) => {
setOptions(
createOptionsFromFields(indexFields, ({ value }) => value.includes(searchValue))
);
}}
selectedOptions={
!!indexFields && !!field.value ? getSelectedItems(field.value, indexFields) : []
}
/>
);
}}
/>
</EuiFormRow>
</EuiFlexItem>

View file

@ -46,7 +46,7 @@ export function transformSloResponseToCreateSloForm(
timesliceWindow: String(toDuration(values.objective.timesliceWindow).value),
}),
},
groupBy: values.groupBy,
groupBy: [values.groupBy].flat(),
tags: values.tags,
};
}
@ -73,7 +73,7 @@ export function transformCreateSLOFormToCreateSLOInput(values: CreateSLOForm): C
}),
},
tags: values.tags,
groupBy: values.groupBy,
groupBy: [values.groupBy].flat(),
};
}
@ -99,7 +99,7 @@ export function transformValuesToUpdateSLOInput(values: CreateSLOForm): UpdateSL
}),
},
tags: values.tags,
groupBy: values.groupBy,
groupBy: [values.groupBy].flat(),
};
}
@ -185,7 +185,7 @@ export function transformPartialUrlStateToFormState(
}
if (values.groupBy) {
state.groupBy = values.groupBy;
state.groupBy = [values.groupBy].flat().filter((group) => !!group) as string[];
}
if (values.timeWindow?.duration && values.timeWindow?.type) {

View file

@ -22,5 +22,5 @@ export interface CreateSLOForm {
timesliceTarget?: number;
timesliceWindow?: string;
};
groupBy: string;
groupBy: string[] | string;
}

View file

@ -17,7 +17,6 @@ import { SloActiveAlertsBadge } from '../../../../components/slo/slo_status_badg
import { SloTimeWindowBadge } from './slo_time_window_badge';
import { SloRulesBadge } from './slo_rules_badge';
import type { SloRule } from '../../../../hooks/slo/use_fetch_rules_for_slo';
import { SloGroupByBadge } from '../../../../components/slo/slo_status_badge/slo_group_by_badge';
export type ViewMode = 'default' | 'compact';
export interface SloBadgesProps {
@ -43,7 +42,6 @@ export function SloBadges({
<>
<SloStatusBadge slo={slo} />
<SloActiveAlertsBadge slo={slo} activeAlerts={activeAlerts} />
<SloGroupByBadge slo={slo} />
<SloIndicatorTypeBadge slo={slo} />
<SloTimeWindowBadge slo={slo} />
<SloRulesBadge rules={rules} onClick={onClickRuleBadge} />

View file

@ -58,7 +58,13 @@ export const useSloCardColor = (status?: SLOWithSummaryResponse['summary']['stat
};
export const getSubTitle = (slo: SLOWithSummaryResponse) => {
return slo.groupBy && slo.groupBy !== ALL_VALUE ? `${slo.groupBy}: ${slo.instanceId}` : '';
return getFirstGroupBy(slo);
};
const getFirstGroupBy = (slo: SLOWithSummaryResponse) => {
const firstGroupBy = Object.entries(slo.groupings).map(([key, value]) => `${key}: ${value}`)[0];
return slo.groupBy && ![slo.groupBy].flat().includes(ALL_VALUE) ? firstGroupBy : '';
};
export function SloCardItem({ slo, rules, activeAlerts, historicalSummary, cardsPerRow }: Props) {

View file

@ -13,11 +13,11 @@ import { EuiFlexGroup } from '@elastic/eui';
import { SloTagsList } from '../common/slo_tags_list';
import { useUrlSearchState } from '../../hooks/use_url_search_state';
import { LoadingBadges } from '../badges/slo_badges';
import { SloIndicatorTypeBadge } from '../badges/slo_indicator_type_badge';
import { SloTimeWindowBadge } from '../badges/slo_time_window_badge';
import { SloActiveAlertsBadge } from '../../../../components/slo/slo_status_badge/slo_active_alerts_badge';
import { SloRulesBadge } from '../badges/slo_rules_badge';
import { SloRule } from '../../../../hooks/slo/use_fetch_rules_for_slo';
import { SLOCardItemInstanceBadge } from './slo_card_item_instance_badge';
interface Props {
hasGroupBy: boolean;
@ -51,7 +51,7 @@ export function SloCardItemBadges({ slo, activeAlerts, rules, handleCreateRule }
) : (
<>
<SloActiveAlertsBadge slo={slo} activeAlerts={activeAlerts} viewMode="compact" />
<SloIndicatorTypeBadge slo={slo} color="default" />
<SLOCardItemInstanceBadge slo={slo} />
<SloTimeWindowBadge slo={slo} color="default" />
<SloRulesBadge rules={rules} onClick={handleCreateRule} />
<SloTagsList

View file

@ -0,0 +1,53 @@
/*
* 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, { useState } from 'react';
import { EuiBadge, EuiFlexItem, EuiPopover } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { SLOGroupings } from '../common/slo_groupings';
export interface Props {
slo: SLOWithSummaryResponse;
}
export function SLOCardItemInstanceBadge({ slo }: Props) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const entries = Object.entries(slo.groupings);
const show = entries.length > 1;
if (!show) {
return null;
}
return (
<EuiFlexItem grow={false}>
<EuiPopover
isOpen={isPopoverOpen}
button={
<EuiBadge
onClick={() => {
setIsPopoverOpen(!isPopoverOpen);
}}
onClickAriaLabel={i18n.translate('xpack.observability.slo.instances.seeAllBadge', {
defaultMessage: 'see all instance ids',
})}
data-test-subj="o11ySlosSeeAllInstanceIdsBadge"
>
{`${i18n.translate('xpack.observability.slos.extraInstanceIds.badge', {
defaultMessage: '+{count, plural, one {# more instance} other {# more instances}}',
values: {
count: entries.length - 1,
},
})}`}
</EuiBadge>
}
>
<SLOGroupings slo={slo} direction="column" truncate={false} />
</EuiPopover>
</EuiFlexItem>
);
}

View file

@ -0,0 +1,133 @@
/*
* 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 { css } from '@emotion/react';
import {
EuiCopy,
EuiFlexGroup,
EuiFlexItem,
EuiText,
EuiAccordion,
EuiButtonEmpty,
EuiLink,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
export interface Props {
slo: SLOWithSummaryResponse | undefined;
direction?: 'column' | 'row';
truncate?: boolean;
}
export function SLOGroupings({ slo, direction = 'row', truncate = true }: Props) {
const groups = Object.entries(slo?.groupings || []);
const shouldTruncate = truncate && groups.length > 3;
const firstThree = shouldTruncate ? groups.slice(0, 3) : groups;
const rest = shouldTruncate ? groups.slice(3, groups.length) : [];
const buttonCss = css`
&:hover {
text-decoration: none;
}
`;
if (!groups.length) {
return null;
}
return (
<>
{shouldTruncate ? (
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiAccordion
id="sloGroupingsAccordion"
arrowDisplay="right"
buttonElement="div"
buttonProps={{ css: buttonCss }}
buttonContent={
<>
<EuiFlexGroup
alignItems={direction === 'column' ? 'flexStart' : 'center'}
gutterSize="s"
direction={direction}
>
<EuiFlexItem>
<Entries entries={firstThree} direction={direction} />{' '}
</EuiFlexItem>
<EuiFlexItem grow={false}>
{rest.length && (
<span>
<EuiButtonEmpty data-test-subj="accordion" flush="left">
{`(${i18n.translate('xpack.observability.slos.andLabel', {
defaultMessage:
'and {count, plural, one {# more instance} other {# more instances}}',
values: {
count: rest.length,
},
})})`}
</EuiButtonEmpty>
</span>
)}
</EuiFlexItem>
</EuiFlexGroup>
</>
}
>
<Entries entries={rest} direction={direction} />
</EuiAccordion>
</EuiFlexItem>
</EuiFlexGroup>
) : (
<Entries entries={truncate ? firstThree : groups} direction={direction} />
)}
</>
);
}
function Entries({
entries,
direction,
}: {
entries: Array<[string, unknown]>;
direction: 'row' | 'column';
}) {
const { euiTheme } = useEuiTheme();
return (
<EuiFlexGroup gutterSize="s" direction={direction}>
{entries.map(([key, value]) => (
<EuiFlexItem grow={false} key={key}>
<EuiText size="s">
<span>
{`${key}: `}
<EuiCopy textToCopy={`${value}`}>
{(copy) => (
<EuiLink
data-test-subj="sloInstanceCopy"
style={{
fontWeight: euiTheme.font.weight.semiBold,
}}
color="text"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
copy();
}}
>
{`${value}`}
</EuiLink>
)}
</EuiCopy>
</span>
</EuiText>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
}

View file

@ -4,7 +4,6 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
DefaultItemAction,
EuiBasicTable,
@ -42,6 +41,7 @@ import { SloListEmpty } from '../slo_list_empty';
import { SloListError } from '../slo_list_error';
import { SloSparkline } from '../slo_sparkline';
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
import { SLOGroupings } from '../common/slo_groupings';
export interface Props {
sloList: SLOWithSummaryResponse[];
@ -114,7 +114,7 @@ export function SloListCompactView({ sloList, loading, error }: Props) {
const sloDetailsUrl = basePath.prepend(
paths.observability.sloDetails(
slo.id,
slo.groupBy !== ALL_VALUE && slo.instanceId ? slo.instanceId : undefined
![slo.groupBy].flat().includes(ALL_VALUE) && slo.instanceId ? slo.instanceId : undefined
)
);
navigateToUrl(sloDetailsUrl);
@ -241,7 +241,7 @@ export function SloListCompactView({ sloList, loading, error }: Props) {
const sloDetailsUrl = basePath.prepend(
paths.observability.sloDetails(
slo.id,
slo.groupBy !== ALL_VALUE && slo.instanceId ? slo.instanceId : undefined
![slo.groupBy].flat().includes(ALL_VALUE) && slo.instanceId ? slo.instanceId : undefined
)
);
return (
@ -267,23 +267,14 @@ export function SloListCompactView({ sloList, loading, error }: Props) {
{
field: 'instance',
name: 'Instance',
render: (_, slo: SLOWithSummaryResponse) =>
slo.groupBy !== ALL_VALUE ? (
<EuiToolTip
position="top"
content={i18n.translate('xpack.observability.slo.groupByBadge', {
defaultMessage: 'Group by {groupKey}',
values: {
groupKey: slo.groupBy,
},
})}
display="block"
>
<span>{slo.instanceId}</span>
</EuiToolTip>
render: (_, slo: SLOWithSummaryResponse) => {
const groups = [slo.groupBy].flat();
return !groups.includes(ALL_VALUE) ? (
<SLOGroupings slo={slo} direction="column" />
) : (
<span>{NOT_AVAILABLE_LABEL}</span>
),
);
},
},
{
field: 'objective',

View file

@ -75,7 +75,7 @@ export function SloItemActions({
const sloDetailsUrl = basePath.prepend(
paths.observability.sloDetails(
slo.id,
slo.groupBy !== ALL_VALUE && slo.instanceId ? slo.instanceId : undefined
![slo.groupBy].flat().includes(ALL_VALUE) && slo.instanceId ? slo.instanceId : undefined
)
);

View file

@ -18,6 +18,7 @@ import { SloItemActions } from './slo_item_actions';
import type { SloRule } from '../../../hooks/slo/use_fetch_rules_for_slo';
import { SloBadges } from './badges/slo_badges';
import { SloSummary } from './slo_summary';
import { SLOGroupings } from './common/slo_groupings';
export interface SloListItemProps {
slo: SLOWithSummaryResponse;
@ -54,13 +55,16 @@ export function SloListItem({
<EuiFlexItem grow>
<EuiFlexGroup>
<EuiFlexItem grow>
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiText size="s">
{slo.summary ? (
<a data-test-subj="o11ySloListItemLink" href={sloDetailsUrl}>
{slo.name}
</a>
<>
<a data-test-subj="o11ySloListItemLink" href={sloDetailsUrl}>
{slo.name}
</a>
<SLOGroupings slo={slo} />
</>
) : (
<span>{slo.name}</span>
)}

View file

@ -48,7 +48,7 @@ export const getSloFormattedSummary = (
const sloDetailsUrl = basePath.prepend(
paths.observability.sloDetails(
slo.id,
slo.groupBy !== ALL_VALUE && slo.instanceId ? slo.instanceId : undefined
![slo.groupBy].flat().includes(ALL_VALUE) && slo.instanceId ? slo.instanceId : undefined
)
);

View file

@ -0,0 +1,15 @@
/*
* 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.
*/
export function getGroupKeysProse(groupBy: string | string[]) {
const groups = [groupBy].flat().map((group) => `"${group}"`);
const groupKeys =
groups.length > 1
? `${groups.slice(0, groups.length - 1).join(', ')} and ${groups.slice(-1)}`
: groups[0];
return groupKeys;
}

View file

@ -11,6 +11,7 @@ import {
historicalSummarySchema,
statusSchema,
summarySchema,
groupingsSchema,
groupSummarySchema,
} from '@kbn/slo-schema';
@ -18,6 +19,7 @@ type Status = t.TypeOf<typeof statusSchema>;
type DateRange = t.TypeOf<typeof dateRangeSchema>;
type HistoricalSummary = t.TypeOf<typeof historicalSummarySchema>;
type Summary = t.TypeOf<typeof summarySchema>;
type Groupings = t.TypeOf<typeof groupingsSchema>;
type GroupSummary = t.TypeOf<typeof groupSummarySchema>;
export type { DateRange, HistoricalSummary, Status, Summary, GroupSummary };
export type { DateRange, Groupings, HistoricalSummary, Status, Summary, GroupSummary };

View file

@ -2,39 +2,48 @@
exports[`SummaryClient fetchSummary with calendar aligned and timeslices SLO returns the summary 1`] = `
Object {
"errorBudget": Object {
"consumed": 0,
"initial": 0.05,
"isEstimated": false,
"remaining": 1,
"groupings": Object {},
"summary": Object {
"errorBudget": Object {
"consumed": 0,
"initial": 0.05,
"isEstimated": false,
"remaining": 1,
},
"sliValue": -1,
"status": "NO_DATA",
},
"sliValue": -1,
"status": "NO_DATA",
}
`;
exports[`SummaryClient fetchSummary with rolling and occurrences SLO returns the summary 1`] = `
Object {
"errorBudget": Object {
"consumed": 0,
"initial": 0.001,
"isEstimated": false,
"remaining": 1,
"groupings": Object {},
"summary": Object {
"errorBudget": Object {
"consumed": 0,
"initial": 0.001,
"isEstimated": false,
"remaining": 1,
},
"sliValue": -1,
"status": "NO_DATA",
},
"sliValue": -1,
"status": "NO_DATA",
}
`;
exports[`SummaryClient fetchSummary with rolling and timeslices SLO returns the summary 1`] = `
Object {
"errorBudget": Object {
"consumed": 0,
"initial": 0.05,
"isEstimated": false,
"remaining": 1,
"groupings": Object {},
"summary": Object {
"errorBudget": Object {
"consumed": 0,
"initial": 0.05,
"isEstimated": false,
"remaining": 1,
},
"sliValue": -1,
"status": "NO_DATA",
},
"sliValue": -1,
"status": "NO_DATA",
}
`;

View file

@ -36,6 +36,7 @@ Object {
"perPage": 20,
"results": Array [
Object {
"groupings": Object {},
"id": "slo-one",
"instanceId": "*",
"summary": Object {
@ -50,6 +51,7 @@ Object {
},
},
Object {
"groupings": Object {},
"id": "slo_two",
"instanceId": "*",
"summary": Object {
@ -64,6 +66,7 @@ Object {
},
},
Object {
"groupings": Object {},
"id": "slo-three",
"instanceId": "*",
"summary": Object {
@ -78,6 +81,7 @@ Object {
},
},
Object {
"groupings": Object {},
"id": "slo-five",
"instanceId": "*",
"summary": Object {
@ -92,6 +96,7 @@ Object {
},
},
Object {
"groupings": Object {},
"id": "slo-four",
"instanceId": "*",
"summary": Object {

View file

@ -96,6 +96,7 @@ describe('FindSLO', () => {
enabled: slo.enabled,
revision: slo.revision,
groupBy: slo.groupBy,
groupings: {},
instanceId: ALL_VALUE,
version: SLO_MODEL_VERSION,
},
@ -166,6 +167,7 @@ function summarySearchResult(slo: SLO): Paginated<SLOSummary> {
{
id: slo.id,
instanceId: slo.groupBy === ALL_VALUE ? ALL_VALUE : 'host-abcde',
groupings: {},
summary: {
status: 'HEALTHY',
sliValue: 0.9999,

View file

@ -48,6 +48,7 @@ function mergeSloWithSummary(sloList: SLO[], sloSummaryList: SLOSummary[]): SLOW
...sloList.find((s) => s.id === sloSummary.id)!,
instanceId: sloSummary.instanceId,
summary: sloSummary.summary,
groupings: sloSummary.groupings,
}));
}

View file

@ -38,6 +38,7 @@ interface Options {
interval: string;
instanceId?: string;
groupBy?: string;
groupings?: Record<string, unknown>;
}
export class GetPreviewData {
constructor(private esClient: ElasticsearchClient) {}
@ -47,11 +48,7 @@ export class GetPreviewData {
options: Options
): Promise<GetPreviewDataResponse> {
const filter: estypes.QueryDslQueryContainer[] = [];
if (options.instanceId !== ALL_VALUE && options.groupBy) {
filter.push({
term: { [options.groupBy]: options.instanceId },
});
}
this.getGroupingsFilter(options, filter);
if (indicator.params.service !== ALL_VALUE)
filter.push({
match: { 'service.name': indicator.params.service },
@ -145,11 +142,7 @@ export class GetPreviewData {
options: Options
): Promise<GetPreviewDataResponse> {
const filter: estypes.QueryDslQueryContainer[] = [];
if (options.instanceId !== ALL_VALUE && options.groupBy) {
filter.push({
term: { [options.groupBy]: options.instanceId },
});
}
this.getGroupingsFilter(options, filter);
if (indicator.params.service !== ALL_VALUE)
filter.push({
match: { 'service.name': indicator.params.service },
@ -242,11 +235,7 @@ export class GetPreviewData {
filterQuery,
];
if (options.instanceId !== ALL_VALUE && options.groupBy) {
filter.push({
term: { [options.groupBy]: options.instanceId },
});
}
this.getGroupingsFilter(options, filter);
const result = await this.esClient.search({
index: indicator.params.index,
@ -305,11 +294,7 @@ export class GetPreviewData {
{ range: { [timestampField]: { gte: options.range.start, lte: options.range.end } } },
filterQuery,
];
if (options.instanceId !== ALL_VALUE && options.groupBy) {
filter.push({
term: { [options.groupBy]: options.instanceId },
});
}
this.getGroupingsFilter(options, filter);
const result = await this.esClient.search({
index: indicator.params.index,
@ -371,11 +356,7 @@ export class GetPreviewData {
filterQuery,
];
if (options.instanceId !== ALL_VALUE && options.groupBy) {
filter.push({
term: { [options.groupBy]: options.instanceId },
});
}
this.getGroupingsFilter(options, filter);
const result = await this.esClient.search({
index: indicator.params.index,
@ -422,11 +403,7 @@ export class GetPreviewData {
filterQuery,
];
if (options.instanceId !== ALL_VALUE && options.groupBy) {
filter.push({
term: { [options.groupBy]: options.instanceId },
});
}
this.getGroupingsFilter(options, filter);
const result = await this.esClient.search({
index: indicator.params.index,
@ -469,6 +446,21 @@ export class GetPreviewData {
}));
}
private getGroupingsFilter(options: Options, filter: estypes.QueryDslQueryContainer[]) {
const groupingsKeys = Object.keys(options.groupings || []);
if (groupingsKeys.length) {
groupingsKeys.forEach((key) => {
filter.push({
term: { [key]: options.groupings?.[key] },
});
});
} else if (options.instanceId !== ALL_VALUE && options.groupBy) {
filter.push({
term: { [options.groupBy]: options.instanceId },
});
}
}
public async execute(params: GetPreviewDataParams): Promise<GetPreviewDataResponse> {
try {
// If the time range is 24h or less, then we want to use a 1m bucket for the
@ -490,6 +482,7 @@ export class GetPreviewData {
instanceId: params.instanceId,
range: params.range,
groupBy: params.groupBy,
groupings: params.groupings,
interval: `${bucketSize}m`,
};

View file

@ -29,13 +29,16 @@ describe('GetSLO', () => {
const slo = createSLO({ indicator: createAPMTransactionErrorRateIndicator() });
mockRepository.findById.mockResolvedValueOnce(slo);
mockSummaryClient.computeSummary.mockResolvedValueOnce({
status: 'HEALTHY',
sliValue: 0.9999,
errorBudget: {
initial: 0.001,
consumed: 0.1,
remaining: 0.9,
isEstimated: false,
groupings: {},
summary: {
status: 'HEALTHY',
sliValue: 0.9999,
errorBudget: {
initial: 0.001,
consumed: 0.1,
remaining: 0.9,
isEstimated: false,
},
},
});
@ -84,6 +87,7 @@ describe('GetSLO', () => {
enabled: slo.enabled,
revision: slo.revision,
groupBy: slo.groupBy,
groupings: {},
instanceId: ALL_VALUE,
version: SLO_MODEL_VERSION,
});

View file

@ -4,9 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ALL_VALUE, GetSLOParams, GetSLOResponse, getSLOResponseSchema } from '@kbn/slo-schema';
import { SLO, Summary } from '../../domain/models';
import { Groupings, SLO, Summary } from '../../domain/models';
import { SLORepository } from './slo_repository';
import { SummaryClient } from './summary_client';
@ -15,13 +14,14 @@ export class GetSLO {
public async execute(sloId: string, params: GetSLOParams = {}): Promise<GetSLOResponse> {
const slo = await this.repository.findById(sloId);
const instanceId = params.instanceId ?? ALL_VALUE;
const summary = await this.summaryClient.computeSummary(slo, instanceId);
return getSLOResponseSchema.encode(mergeSloWithSummary(slo, summary, instanceId));
const instanceId = params.instanceId ?? ALL_VALUE;
const { summary, groupings } = await this.summaryClient.computeSummary(slo, instanceId);
return getSLOResponseSchema.encode(mergeSloWithSummary(slo, summary, instanceId, groupings));
}
}
function mergeSloWithSummary(slo: SLO, summary: Summary, instanceId: string) {
return { ...slo, instanceId, summary };
function mergeSloWithSummary(slo: SLO, summary: Summary, instanceId: string, groupings: Groupings) {
return { ...slo, instanceId, summary, groupings };
}

View file

@ -16,7 +16,7 @@ export class GetSLOInstances {
public async execute(sloId: string): Promise<GetSLOInstancesResponse> {
const slo = await this.repository.findById(sloId);
if (slo.groupBy === ALL_VALUE) {
if ([slo.groupBy].flat().includes(ALL_VALUE)) {
return { groupBy: ALL_VALUE, instances: [] };
}

View file

@ -215,7 +215,7 @@ function generateSearchQuery(
getFixedIntervalAndBucketsPerDay(timeWindowDurationInDays);
const extraFilterByInstanceId =
!!slo.groupBy && slo.groupBy !== ALL_VALUE && instanceId !== ALL_VALUE
!!slo.groupBy && ![slo.groupBy].flat().includes(ALL_VALUE) && instanceId !== ALL_VALUE
? [{ term: { 'slo.instanceId': instanceId } }]
: [];

View file

@ -16,23 +16,50 @@ import {
} from '@kbn/slo-schema';
import moment from 'moment';
import { SLO_DESTINATION_INDEX_PATTERN } from '../../../common/slo/constants';
import { DateRange, SLO, Summary } from '../../domain/models';
import { DateRange, SLO, Summary, Groupings } from '../../domain/models';
import { computeSLI, computeSummaryStatus, toErrorBudget } from '../../domain/services';
import { toDateRange } from '../../domain/services/date_range';
import { getFlattenedGroupings } from './utils';
export interface SummaryClient {
computeSummary(slo: SLO, instanceId?: string): Promise<Summary>;
computeSummary(
slo: SLO,
groupings?: string,
instanceId?: string
): Promise<{ summary: Summary; groupings: Groupings }>;
}
export class DefaultSummaryClient implements SummaryClient {
constructor(private esClient: ElasticsearchClient) {}
async computeSummary(slo: SLO, instanceId: string = ALL_VALUE): Promise<Summary> {
async computeSummary(
slo: SLO,
instanceId: string = ALL_VALUE
): Promise<{ summary: Summary; groupings: Groupings }> {
const dateRange = toDateRange(slo.timeWindow);
const isDefinedWithGroupBy = slo.groupBy !== ALL_VALUE;
const isDefinedWithGroupBy = ![slo.groupBy].flat().includes(ALL_VALUE);
const hasInstanceId = instanceId !== ALL_VALUE;
const extraInstanceIdFilter =
isDefinedWithGroupBy && hasInstanceId ? [{ term: { 'slo.instanceId': instanceId } }] : [];
const includeInstanceIdQueries = isDefinedWithGroupBy && hasInstanceId;
const extraInstanceIdFilter = includeInstanceIdQueries
? [{ term: { 'slo.instanceId': instanceId } }]
: [];
const extraGroupingsAgg = {
last_doc: {
top_hits: {
sort: [
{
'@timestamp': {
order: 'desc',
},
},
],
_source: {
includes: ['slo.groupings'],
},
size: 1,
},
},
};
const result = await this.esClient.search({
index: SLO_DESTINATION_INDEX_PATTERN,
@ -51,28 +78,30 @@ export class DefaultSummaryClient implements SummaryClient {
],
},
},
...(occurrencesBudgetingMethodSchema.is(slo.budgetingMethod) && {
aggs: {
good: { sum: { field: 'slo.numerator' } },
total: { sum: { field: 'slo.denominator' } },
},
}),
...(timeslicesBudgetingMethodSchema.is(slo.budgetingMethod) && {
aggs: {
// @ts-expect-error AggregationsAggregationContainer needs to be updated with top_hits
aggs: {
...(includeInstanceIdQueries && extraGroupingsAgg),
...(timeslicesBudgetingMethodSchema.is(slo.budgetingMethod) && {
good: {
sum: { field: 'slo.isGoodSlice' },
},
total: {
value_count: { field: 'slo.isGoodSlice' },
},
},
}),
}),
...(occurrencesBudgetingMethodSchema.is(slo.budgetingMethod) && {
good: { sum: { field: 'slo.numerator' } },
total: { sum: { field: 'slo.denominator' } },
}),
},
});
// @ts-ignore value is not type correctly
const good = result.aggregations?.good?.value ?? 0;
// @ts-ignore value is not type correctly
const total = result.aggregations?.total?.value ?? 0;
// @ts-expect-error AggregationsAggregationContainer needs to be updated with top_hits
const groupings = result.aggregations?.last_doc?.hits?.hits?.[0]?._source?.slo?.groupings;
const sliValue = computeSLI(good, total);
const initialErrorBudget = 1 - slo.objective.target;
@ -100,9 +129,12 @@ export class DefaultSummaryClient implements SummaryClient {
}
return {
sliValue,
errorBudget,
status: computeSummaryStatus(slo, sliValue, errorBudget),
summary: {
sliValue,
errorBudget,
status: computeSummaryStatus(slo, sliValue, errorBudget),
},
groupings: groupings ? getFlattenedGroupings({ groupBy: slo.groupBy, groupings }) : {},
};
}
}

View file

@ -8,11 +8,12 @@
import { ElasticsearchClient, Logger } from '@kbn/core/server';
import { ALL_VALUE, Paginated, Pagination } from '@kbn/slo-schema';
import { assertNever } from '@kbn/std';
import _ from 'lodash';
import { partition } from 'lodash';
import { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types';
import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from '../../../common/slo/constants';
import { SLOId, Status, Summary } from '../../domain/models';
import { SLOId, Status, Summary, Groupings } from '../../domain/models';
import { toHighPrecision } from '../../utils/number';
import { getFlattenedGroupings } from './utils';
import { getElasticsearchQueryOrThrow } from './transform_generators';
interface EsSummaryDocument {
@ -20,6 +21,8 @@ interface EsSummaryDocument {
id: string;
revision: number;
instanceId: string;
groupings: Groupings;
groupBy: string[];
};
sliValue: number;
errorBudgetConsumed: number;
@ -35,6 +38,7 @@ export interface SLOSummary {
id: SLOId;
instanceId: string;
summary: Summary;
groupings: Groupings;
}
export type SortField = 'error_budget_consumed' | 'error_budget_remaining' | 'sli_value' | 'status';
@ -105,7 +109,7 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
return { total: 0, perPage: pagination.perPage, page: pagination.page, results: [] };
}
const [tempSummaryDocuments, summaryDocuments] = _.partition(
const [tempSummaryDocuments, summaryDocuments] = partition(
summarySearch.hits.hits,
(doc) => !!doc._source?.isTempDoc
);
@ -149,6 +153,10 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
sliValue: toHighPrecision(doc._source!.sliValue),
status: doc._source!.status,
},
groupings: getFlattenedGroupings({
groupings: doc._source!.slo.groupings,
groupBy: doc._source!.slo.groupBy,
}),
})),
};
} catch (err) {

View file

@ -9,9 +9,11 @@ import { ALL_VALUE } from '@kbn/slo-schema';
import { SLO } from '../../../../domain/models/slo';
export const getGroupBy = (slo: SLO) => {
const groups = [slo.groupBy].flat().filter((group) => !!group);
const groupings =
slo.groupBy !== '' && slo.groupBy !== ALL_VALUE
? [slo.groupBy].flat().reduce((acc, field) => {
!groups.includes(ALL_VALUE) && groups.length
? groups.reduce((acc, field) => {
return {
...acc,
[`slo.groupings.${field}`]: {

View file

@ -0,0 +1,200 @@
/*
* 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 { createAPMTransactionErrorRateIndicator, createSLO } from '../fixtures/slo';
import { ApmTransactionErrorRateTransformGenerator } from './apm_transaction_error_rate';
const generator = new ApmTransactionErrorRateTransformGenerator();
describe('Transform Generator', () => {
it('builds common runtime mappings without group by', async () => {
const slo = createSLO({
id: 'irrelevant',
indicator: createAPMTransactionErrorRateIndicator(),
});
const transform = generator.buildCommonRuntimeMappings(slo);
expect(transform).toEqual({
'slo.id': {
script: {
source: "emit('irrelevant')",
},
type: 'keyword',
},
'slo.instanceId': {
script: {
source: "emit('*')",
},
type: 'keyword',
},
'slo.revision': {
script: {
source: 'emit(1)',
},
type: 'long',
},
});
const commonGroupBy = generator.buildCommonGroupBy(slo);
expect(commonGroupBy).toEqual({
'@timestamp': {
date_histogram: {
field: '@timestamp',
fixed_interval: '1m',
},
},
'slo.id': {
terms: {
field: 'slo.id',
},
},
'slo.instanceId': {
terms: {
field: 'slo.instanceId',
},
},
'slo.revision': {
terms: {
field: 'slo.revision',
},
},
});
});
it.each(['example', ['example']])(
'builds common runtime mappings and group by with single group by',
async (groupBy) => {
const indicator = createAPMTransactionErrorRateIndicator();
const slo = createSLO({
id: 'irrelevant',
groupBy,
indicator,
});
const commonRuntime = generator.buildCommonRuntimeMappings(slo);
expect(commonRuntime).toEqual({
'slo.id': {
script: {
source: "emit('irrelevant')",
},
type: 'keyword',
},
'slo.instanceId': {
script: {
source: "emit('example:'+doc['example'].value)",
},
type: 'keyword',
},
'slo.revision': {
script: {
source: 'emit(1)',
},
type: 'long',
},
});
const commonGroupBy = generator.buildCommonGroupBy(slo);
expect(commonGroupBy).toEqual({
'@timestamp': {
date_histogram: {
field: '@timestamp',
fixed_interval: '1m',
},
},
'slo.groupings.example': {
terms: {
field: 'example',
},
},
'slo.id': {
terms: {
field: 'slo.id',
},
},
'slo.instanceId': {
terms: {
field: 'slo.instanceId',
},
},
'slo.revision': {
terms: {
field: 'slo.revision',
},
},
});
}
);
it('builds common runtime mappings without multi group by', async () => {
const indicator = createAPMTransactionErrorRateIndicator();
const slo = createSLO({
id: 'irrelevant',
groupBy: ['example1', 'example2'],
indicator,
});
const commonRuntime = generator.buildCommonRuntimeMappings(slo);
expect(commonRuntime).toEqual({
'slo.id': {
script: {
source: "emit('irrelevant')",
},
type: 'keyword',
},
'slo.instanceId': {
script: {
source: "emit('example1:'+doc['example1'].value+'|'+'example2:'+doc['example2'].value)",
},
type: 'keyword',
},
'slo.revision': {
script: {
source: 'emit(1)',
},
type: 'long',
},
});
const commonGroupBy = generator.buildCommonGroupBy(slo);
expect(commonGroupBy).toEqual({
'@timestamp': {
date_histogram: {
field: '@timestamp',
fixed_interval: '1m',
},
},
'slo.groupings.example1': {
terms: {
field: 'example1',
},
},
'slo.groupings.example2': {
terms: {
field: 'example2',
},
},
'slo.id': {
terms: {
field: 'slo.id',
},
},
'slo.instanceId': {
terms: {
field: 'slo.instanceId',
},
},
'slo.revision': {
terms: {
field: 'slo.revision',
},
},
});
});
});

View file

@ -17,8 +17,8 @@ export abstract class TransformGenerator {
public abstract getTransformParams(slo: SLO): TransformPutTransformRequest;
public buildCommonRuntimeMappings(slo: SLO): MappingRuntimeFields {
const mustIncludeAllInstanceId = slo.groupBy === ALL_VALUE || slo.groupBy === '';
const groupings = [slo.groupBy].flat().filter((value) => !!value);
const hasGroupings = !groupings.includes(ALL_VALUE) && groupings.length;
return {
'slo.id': {
type: 'keyword',
@ -32,17 +32,32 @@ export abstract class TransformGenerator {
source: `emit(${slo.revision})`,
},
},
...(mustIncludeAllInstanceId && {
'slo.instanceId': {
type: 'keyword',
script: {
source: `emit('${ALL_VALUE}')`,
},
},
}),
...(hasGroupings
? {
'slo.instanceId': {
type: 'keyword',
script: {
source: this.buildInstanceId(slo),
},
},
}
: {
'slo.instanceId': {
type: 'keyword',
script: {
source: `emit('${ALL_VALUE}')`,
},
},
}),
};
}
public buildInstanceId(slo: SLO): string {
const groups = [slo.groupBy].flat().filter((value) => !!value);
const groupings = groups.map((group) => `'${group}:'+doc['${group}'].value`).join(`+'|'+`);
return `emit(${groupings})`;
}
public buildDescription(slo: SLO): string {
return `Rolled-up SLI data for SLO: ${slo.name} [id: ${slo.id}, revision: ${slo.revision}]`;
}
@ -57,9 +72,11 @@ export abstract class TransformGenerator {
fixedInterval = slo.objective.timesliceWindow!.format();
}
const groups = [slo.groupBy].flat().filter((group) => !!group);
const groupings =
slo.groupBy !== '' && slo.groupBy !== ALL_VALUE
? [slo.groupBy].flat().reduce(
!groups.includes(ALL_VALUE) && groups.length
? groups.reduce(
(acc, field) => {
return {
...acc,
@ -70,7 +87,11 @@ export abstract class TransformGenerator {
},
};
},
{ 'slo.instanceId': { terms: { field: slo.groupBy } } }
{
'slo.instanceId': {
terms: { field: 'slo.instanceId' },
},
}
)
: { 'slo.instanceId': { terms: { field: 'slo.instanceId' } } };

View file

@ -0,0 +1,52 @@
/*
* 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 { getFlattenedGroupings } from '.';
describe('utils', () => {
describe('getFlattenedGroupings', () => {
it.each(['a.nested.key', ['a.nested.key']])(
'handles single group by with nested keys',
(groupBy) => {
const groupings = {
a: {
nested: {
key: 'value',
},
},
};
expect(getFlattenedGroupings({ groupBy, groupings })).toEqual({ 'a.nested.key': 'value' });
}
);
it.each(['not_nested', ['not_nested']])(
'handles single group by with regular keys',
(groupBy) => {
const groupings = {
not_nested: 'value',
};
expect(getFlattenedGroupings({ groupBy, groupings })).toEqual({ not_nested: 'value' });
}
);
it('handles multi group by with nested and regular keys', () => {
const groupBy = ['a.nested.key', 'not_nested'];
const groupings = {
not_nested: 'value2',
a: {
nested: {
key: 'value',
},
},
};
expect(getFlattenedGroupings({ groupBy, groupings })).toEqual({
'a.nested.key': 'value',
not_nested: 'value2',
});
});
});
});

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { get } from 'lodash';
import { Groupings } from '../../../domain/models';
/**
* Takes a list of groupBy fields and the nested groupings object provided from
* ES search results and returns a flatted object with the `groupBy` fields as keys
* @param groupBy an array of groupBy fields
* @param groupings a nested object of groupings
* @returns a flattened object of groupings
*/
export const getFlattenedGroupings = ({
groupBy,
groupings,
}: {
groupBy: string[] | string;
groupings: Record<string, unknown>;
}): Groupings => {
const groupByFields = groupBy ? [groupBy].flat() : [];
const hasGroupings = Object.keys(groupings || []).length;
const formattedGroupings = hasGroupings
? groupByFields.reduce<Groupings>((acc, group) => {
acc[group] = `${get(groupings, group)}`;
return acc;
}, {})
: {};
return formattedGroupings;
};

View file

@ -129,6 +129,12 @@ export default function ({ getService }: FtrProviderContext) {
script: { source: `emit('${id}')` },
},
'slo.revision': { type: 'long', script: { source: 'emit(1)' } },
'slo.instanceId': {
script: {
source: "emit('tags:'+doc['tags'].value)",
},
type: 'keyword',
},
},
},
dest: {
@ -141,7 +147,7 @@ export default function ({ getService }: FtrProviderContext) {
group_by: {
'slo.id': { terms: { field: 'slo.id' } },
'slo.revision': { terms: { field: 'slo.revision' } },
'slo.instanceId': { terms: { field: 'tags' } },
'slo.instanceId': { terms: { field: 'slo.instanceId' } },
'slo.groupings.tags': { terms: { field: 'tags' } },
'@timestamp': { date_histogram: { field: '@timestamp', fixed_interval: '1m' } },
},

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { cleanup } from '@kbn/infra-forge';
import expect from '@kbn/expect';
import expect from 'expect';
import type { CreateSLOInput } from '@kbn/slo-schema';
import { FtrProviderContext } from '../../ftr_provider_context';
@ -44,7 +44,9 @@ export default function ({ getService }: FtrProviderContext) {
});
afterEach(async () => {
await slo.deleteAllSLOs();
await retry.tryForTime(60 * 1000, async () => {
await slo.deleteAllSLOs();
});
});
after(async () => {
@ -64,7 +66,7 @@ export default function ({ getService }: FtrProviderContext) {
.send()
.expect(200);
expect(getResponse.body).eql({
expect(getResponse.body).toEqual({
name: 'Test SLO for api integration',
description: 'Fixture for api integration tests',
indicator: {
@ -82,6 +84,7 @@ export default function ({ getService }: FtrProviderContext) {
objective: { target: 0.99 },
tags: ['test'],
groupBy: '*',
groupings: {},
id,
settings: { syncDelay: '1m', frequency: '1m' },
revision: 1,
@ -121,7 +124,7 @@ export default function ({ getService }: FtrProviderContext) {
.expect(200);
// expect summary transform to be created
expect(getResponse.body).eql({
expect(getResponse.body).toEqual({
name: 'Test SLO for api integration',
description: 'Fixture for api integration tests',
indicator: {
@ -139,6 +142,7 @@ export default function ({ getService }: FtrProviderContext) {
objective: { target: 0.99 },
tags: ['test'],
groupBy: '*',
groupings: {},
id,
settings: { syncDelay: '1m', frequency: '1m' },
revision: 1,
@ -184,7 +188,7 @@ export default function ({ getService }: FtrProviderContext) {
.expect(200);
// expect summary transform to be created
expect(getResponse.body).eql({
expect(getResponse.body).toEqual({
name: 'Test SLO for api integration',
description: 'Fixture for api integration tests',
indicator: {
@ -206,6 +210,7 @@ export default function ({ getService }: FtrProviderContext) {
},
tags: ['test'],
groupBy: '*',
groupings: {},
id,
settings: { syncDelay: '1m', frequency: '1m' },
revision: 1,
@ -250,7 +255,7 @@ export default function ({ getService }: FtrProviderContext) {
.send()
.expect(200);
expect(getResponse.body).eql({
expect(getResponse.body).toEqual({
name: 'Test SLO for api integration',
description: 'Fixture for api integration tests',
indicator: {
@ -272,6 +277,7 @@ export default function ({ getService }: FtrProviderContext) {
},
tags: ['test'],
groupBy: '*',
groupings: {},
id,
settings: { syncDelay: '1m', frequency: '1m' },
revision: 1,
@ -295,7 +301,7 @@ export default function ({ getService }: FtrProviderContext) {
});
it('gets slos by query', async () => {
const id = await createSLO();
await createSLO();
await createSLO({ name: 'test int' });
await retry.tryForTime(300 * 1000, async () => {
@ -305,7 +311,7 @@ export default function ({ getService }: FtrProviderContext) {
.send()
.expect(200);
expect(response.body.results.length).eql(2);
expect(response.body.results.length).toEqual(2);
const searchResponse = await supertestAPI
.get(`/api/observability/slos?kqlQuery=slo.name%3Aapi*`)
@ -313,7 +319,7 @@ export default function ({ getService }: FtrProviderContext) {
.send()
.expect(200);
expect(searchResponse.body.results.length).eql(1);
expect(searchResponse.body.results.length).toEqual(1);
const searchResponse2 = await supertestAPI
.get(`/api/observability/slos?kqlQuery=slo.name%3Aint`)
@ -321,7 +327,7 @@ export default function ({ getService }: FtrProviderContext) {
.send()
.expect(200);
expect(searchResponse2.body.results.length).eql(1);
expect(searchResponse2.body.results.length).toEqual(1);
const searchResponse3 = await supertestAPI
.get(`/api/observability/slos?kqlQuery=slo.name%3Aint*`)
@ -329,7 +335,7 @@ export default function ({ getService }: FtrProviderContext) {
.send()
.expect(200);
expect(searchResponse3.body.results.length).eql(2);
expect(searchResponse3.body.results.length).toEqual(2);
const searchResponse4 = await supertestAPI
.get(`/api/observability/slos?kqlQuery=int*`)
@ -337,7 +343,25 @@ export default function ({ getService }: FtrProviderContext) {
.send()
.expect(200);
expect(searchResponse4.body.results.length).eql(2);
expect(searchResponse4.body.results.length).toEqual(2);
});
});
it('gets slos instances', async () => {
const id = await createSLO();
await retry.tryForTime(400 * 1000, async () => {
const response = await supertestAPI
.get(`/api/observability/slos`)
.set('kbn-xsrf', 'true')
.send()
.expect(200);
expect(response.body.results.length).toEqual(3);
response.body.results.forEach((result: Record<string, unknown>, i: number) => {
expect(result.groupings).toEqual(expect.objectContaining({ tags: `${i + 1}` }));
});
const instanceResponse = await supertestAPI
.get(`/internal/observability/slos/${id}/_instances`)
@ -346,8 +370,8 @@ export default function ({ getService }: FtrProviderContext) {
.expect(200);
// expect 3 instances to be created
expect(instanceResponse.body.groupBy).eql('tags');
expect(instanceResponse.body.instances.sort()).eql(['1', '2', '3']);
expect(instanceResponse.body.groupBy).toEqual('tags');
expect(instanceResponse.body.instances.sort()).toEqual(['tags:1', 'tags:2', 'tags:3']);
});
});
@ -360,7 +384,7 @@ export default function ({ getService }: FtrProviderContext) {
.send()
.expect(200);
expect(response.body).eql({
expect(response.body).toEqual({
page: 1,
perPage: 100,
results: [
@ -443,7 +467,7 @@ export default function ({ getService }: FtrProviderContext) {
.send()
.expect(200);
expect(searchResponse.body.total).eql(1);
expect(searchResponse.body.total).toEqual(1);
const searchResponse2 = await supertestAPI
.get(`/api/observability/slos/_definitions?search=int`)
@ -451,7 +475,7 @@ export default function ({ getService }: FtrProviderContext) {
.send()
.expect(200);
expect(searchResponse2.body.total).eql(1);
expect(searchResponse2.body.total).toEqual(1);
const searchResponse3 = await supertestAPI
.get(`/api/observability/slos/_definitions?search=int*`)
@ -459,7 +483,7 @@ export default function ({ getService }: FtrProviderContext) {
.send()
.expect(200);
expect(searchResponse3.body.total).eql(2);
expect(searchResponse3.body.total).toEqual(2);
});
});
}

View file

@ -146,6 +146,12 @@ export default function ({ getService }: FtrProviderContext) {
script: { source: `emit('${id}')` },
},
'slo.revision': { type: 'long', script: { source: 'emit(2)' } },
'slo.instanceId': {
script: {
source: "emit('hosts:'+doc['hosts'].value)",
},
type: 'keyword',
},
},
},
dest: {
@ -158,7 +164,7 @@ export default function ({ getService }: FtrProviderContext) {
group_by: {
'slo.id': { terms: { field: 'slo.id' } },
'slo.revision': { terms: { field: 'slo.revision' } },
'slo.instanceId': { terms: { field: 'hosts' } },
'slo.instanceId': { terms: { field: 'slo.instanceId' } },
'slo.groupings.hosts': { terms: { field: 'hosts' } },
'@timestamp': { date_histogram: { field: '@timestamp', fixed_interval: '1m' } },
},

View file

@ -37,15 +37,13 @@ export function SloApiProvider({ getService }: FtrProviderContext) {
.set('kbn-xsrf', 'true')
.send()
.expect(200);
await Promise.all(
(response.body as FindSLODefinitionsResponse).results.map(({ id }) => {
return supertest
.delete(`/api/observability/slos/${id}`)
.set('kbn-xsrf', 'true')
.send()
.expect(204);
})
);
for (const { id } of (response.body as FindSLODefinitionsResponse).results) {
await supertest
.delete(`/api/observability/slos/${id}`)
.set('kbn-xsrf', 'true')
.send()
.expect(204);
}
},
};
}