Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SLO] Add support for group by to SLO burn rate rule #163434

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -81,6 +81,7 @@ const ObservabilitySloAlertOptional = rt.partial({
}),
slo: rt.partial({
id: schemaString,
instanceId: schemaString,
revision: schemaStringOrNumber,
}),
});
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/observability/common/field_names/slo.ts
Expand Up @@ -7,3 +7,4 @@

export const SLO_ID_FIELD = 'slo.id';
export const SLO_REVISION_FIELD = 'slo.revision';
export const SLO_INSTANCE_ID_FIELD = 'slo.instanceId';
Expand Up @@ -7,9 +7,10 @@

import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public';
import React, { useEffect, useState } from 'react';
import { SLOResponse } from '@kbn/slo-schema';
import { ALL_VALUE, SLOResponse } from '@kbn/slo-schema';

import { EuiSpacer, EuiTitle } from '@elastic/eui';
import { EuiCallOut, EuiSpacer, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { BurnRateRuleParams, WindowSchema } from '../../typings';
import { SloSelector } from './slo_selector';
Expand Down Expand Up @@ -98,6 +99,20 @@ export function BurnRateRuleEditor(props: Props) {
</EuiTitle>
<EuiSpacer size="s" />
<SloSelector initialSlo={selectedSlo} onSelected={onSelectedSlo} errors={errors.sloId} />
{selectedSlo?.groupBy && selectedSlo.groupBy !== ALL_VALUE && (
<>
<EuiSpacer size="l" />
<EuiCallOut
color="warning"
size="s"
title={i18n.translate('xpack.observability.slo.rules.groupByMessage', {
defaultMessage:
'The SLO you selected has been created with a partition on "{groupByField}". This rule will monitor and generate an alert for every instance found in the partition field.',
values: { groupByField: selectedSlo.groupBy },
})}
/>
</>
)}
<EuiSpacer size="l" />
<EuiTitle size="xs">
<h5>Define multiple burn rate windows</h5>
Expand Down
Expand Up @@ -11,26 +11,26 @@ import { wait } from '@testing-library/user-event/dist/utils';
import React from 'react';

import { emptySloList } from '../../data/slo/slo';
import { useFetchSloList } from '../../hooks/slo/use_fetch_slo_list';
import { useFetchSloDefinitions } from '../../hooks/slo/use_fetch_slo_definitions';
import { render } from '../../utils/test_helper';
import { SloSelector } from './slo_selector';

jest.mock('../../hooks/slo/use_fetch_slo_list');
jest.mock('../../hooks/slo/use_fetch_slo_definitions');

const useFetchSloListMock = useFetchSloList as jest.Mock;
const useFetchSloDefinitionsMock = useFetchSloDefinitions as jest.Mock;

describe('SLO Selector', () => {
const onSelectedSpy = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
useFetchSloListMock.mockReturnValue({ isLoading: true, sloList: emptySloList });
useFetchSloDefinitionsMock.mockReturnValue({ isLoading: true, data: emptySloList });
});

it('fetches SLOs asynchronously', async () => {
render(<SloSelector onSelected={onSelectedSpy} />);

expect(screen.getByTestId('sloSelector')).toBeTruthy();
expect(useFetchSloListMock).toHaveBeenCalledWith({ kqlQuery: 'slo.name:*' });
expect(useFetchSloDefinitionsMock).toHaveBeenCalledWith({ name: '' });
});

it('searches SLOs when typing', async () => {
Expand All @@ -42,6 +42,6 @@ describe('SLO Selector', () => {
await wait(310); // debounce delay
});

expect(useFetchSloListMock).toHaveBeenCalledWith({ kqlQuery: 'slo.name:latency*' });
expect(useFetchSloDefinitionsMock).toHaveBeenCalledWith({ name: 'latency' });
});
});
Expand Up @@ -10,8 +10,7 @@ import { i18n } from '@kbn/i18n';
import { SLOResponse } from '@kbn/slo-schema';
import { debounce } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';

import { useFetchSloList } from '../../hooks/slo/use_fetch_slo_list';
import { useFetchSloDefinitions } from '../../hooks/slo/use_fetch_slo_definitions';

interface Props {
initialSlo?: SLOResponse;
Expand All @@ -23,7 +22,7 @@ function SloSelector({ initialSlo, onSelected, errors }: Props) {
const [options, setOptions] = useState<Array<EuiComboBoxOptionOption<string>>>([]);
const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>();
const [searchValue, setSearchValue] = useState<string>('');
const { isLoading, sloList } = useFetchSloList({ kqlQuery: `slo.name:${searchValue}*` });
const { isLoading, data: sloList } = useFetchSloDefinitions({ name: searchValue });
const hasError = errors !== undefined && errors.length > 0;

useEffect(() => {
Expand All @@ -33,15 +32,15 @@ function SloSelector({ initialSlo, onSelected, errors }: Props) {
useEffect(() => {
const isLoadedWithData = !isLoading && sloList !== undefined;
const opts: Array<EuiComboBoxOptionOption<string>> = isLoadedWithData
? sloList.results.map((slo) => ({ value: slo.id, label: slo.name }))
? sloList.map((slo) => ({ value: slo.id, label: slo.name }))
: [];
setOptions(opts);
}, [isLoading, sloList]);

const onChange = (opts: Array<EuiComboBoxOptionOption<string>>) => {
setSelectedOptions(opts);
const selectedSlo =
opts.length === 1 ? sloList?.results.find((slo) => slo.id === opts[0].value) : undefined;
opts.length === 1 ? sloList?.find((slo) => slo.id === opts[0].value) : undefined;
onSelected(selectedSlo);
};

Expand Down
Expand Up @@ -25,4 +25,4 @@ const Template: ComponentStory<typeof Component> = (props: Props) => (
);

export const Default = Template.bind({});
Default.args = { activeAlerts: { count: 2 } };
Default.args = { activeAlerts: 2 };
Expand Up @@ -12,10 +12,9 @@ import { SLOWithSummaryResponse } from '@kbn/slo-schema';

import { paths } from '../../../../common/locators/paths';
import { useKibana } from '../../../utils/kibana_react';
import { ActiveAlerts } from '../../../hooks/slo/use_fetch_active_alerts';

export interface Props {
activeAlerts?: ActiveAlerts;
activeAlerts?: number;
slo: SLOWithSummaryResponse;
}

Expand Down Expand Up @@ -53,7 +52,7 @@ export function SloActiveAlertsBadge({ slo, activeAlerts }: Props) {
>
{i18n.translate('xpack.observability.slo.slo.activeAlertsBadge.label', {
defaultMessage: '{count, plural, one {# alert} other {# alerts}}',
values: { count: activeAlerts.count },
values: { count: activeAlerts },
})}
</EuiBadge>
</EuiFlexItem>
Expand Down
Expand Up @@ -5,23 +5,24 @@
* 2.0.
*/

import { UseFetchActiveAlerts } from '../use_fetch_active_alerts';
import { ActiveAlerts, UseFetchActiveAlerts } from '../use_fetch_active_alerts';

export const useFetchActiveAlerts = ({
sloIds = [],
sloIdsAndInstanceIds = [],
}: {
sloIds: string[];
sloIdsAndInstanceIds: Array<[string, string]>;
}): UseFetchActiveAlerts => {
const data = sloIdsAndInstanceIds.reduce(
(acc, item, index) => ({
...acc,
...(index % 2 === 0 && { [item.join('|')]: 2 }),
}),
{}
);
return {
isLoading: false,
isSuccess: false,
isError: false,
data: sloIds.reduce(
(acc, sloId, index) => ({
...acc,
...(index % 2 === 0 && { [sloId]: { count: 2, ruleIds: ['rule-1', 'rule-2'] } }),
}),
{}
),
data: new ActiveAlerts(data),
};
};
Expand Up @@ -29,7 +29,8 @@ export const sloKeys = {
rules: () => [...sloKeys.all, 'rules'] as const,
rule: (sloIds: string[]) => [...sloKeys.rules(), sloIds] as const,
activeAlerts: () => [...sloKeys.all, 'activeAlerts'] as const,
activeAlert: (sloIds: string[]) => [...sloKeys.activeAlerts(), sloIds] as const,
activeAlert: (sloIdsAndInstanceIds: Array<[string, string]>) =>
[...sloKeys.activeAlerts(), ...sloIdsAndInstanceIds.flat()] as const,
historicalSummaries: () => [...sloKeys.all, 'historicalSummary'] as const,
historicalSummary: (list: Array<{ sloId: string; instanceId: string }>) =>
[...sloKeys.historicalSummaries(), list] as const,
Expand Down
Expand Up @@ -8,23 +8,50 @@
import { useQuery } from '@tanstack/react-query';
import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common';

import { ALL_VALUE, SLOResponse } from '@kbn/slo-schema';
import { useKibana } from '../../utils/kibana_react';
import { sloKeys } from './query_key_factory';

type SloId = string;
type SLO = Pick<SLOResponse, 'id' | 'instanceId'>;

interface Params {
sloIds: SloId[];
}
export class ActiveAlerts {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻 Nice abstraction

private data: Map<string, number> = new Map();

constructor(initialData?: Record<string, number>) {
if (initialData) {
Object.keys(initialData).forEach((key) => this.data.set(key, initialData[key]));
}
}

set(slo: SLO, value: number) {
this.data.set(`${slo.id}|${slo.instanceId ?? ALL_VALUE}`, value);
}

get(slo: SLO) {
return this.data.get(`${slo.id}|${slo.instanceId ?? ALL_VALUE}`);
}

has(slo: SLO) {
return this.data.has(`${slo.id}|${slo.instanceId ?? ALL_VALUE}`);
}

delete(slo: SLO) {
return this.data.delete(`${slo.id}|${slo.instanceId ?? ALL_VALUE}`);
}

export interface ActiveAlerts {
count: number;
clear() {
return this.data.clear();
}
}

type ActiveAlertsMap = Record<SloId, ActiveAlerts>;
type SloIdAndInstanceId = [string, string];

interface Params {
sloIdsAndInstanceIds: SloIdAndInstanceId[];
}

export interface UseFetchActiveAlerts {
data: ActiveAlertsMap;
data: ActiveAlerts;
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
Expand All @@ -34,20 +61,21 @@ interface FindApiResponse {
aggregations: {
perSloId: {
buckets: Array<{
key: string;
key: SloIdAndInstanceId;
key_as_string: string;
doc_count: number;
}>;
};
};
}

const EMPTY_ACTIVE_ALERTS_MAP = {};
const EMPTY_ACTIVE_ALERTS_MAP = new ActiveAlerts();

export function useFetchActiveAlerts({ sloIds = [] }: Params): UseFetchActiveAlerts {
export function useFetchActiveAlerts({ sloIdsAndInstanceIds = [] }: Params): UseFetchActiveAlerts {
const { http } = useKibana().services;

const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({
queryKey: sloKeys.activeAlert(sloIds),
queryKey: sloKeys.activeAlert(sloIdsAndInstanceIds),
queryFn: async ({ signal }) => {
try {
const response = await http.post<FindApiResponse>(`${BASE_RAC_ALERTS_API_PATH}/find`, {
Expand Down Expand Up @@ -75,37 +103,33 @@ export function useFetchActiveAlerts({ sloIds = [] }: Params): UseFetchActiveAle
},
},
],
should: [
{
terms: {
'kibana.alert.rule.parameters.sloId': sloIds,
},
should: sloIdsAndInstanceIds.map(([sloId, instanceId]) => ({
bool: {
filter: [
{ term: { 'slo.id': sloId } },
{ term: { 'slo.instanceId': instanceId } },
simianhacker marked this conversation as resolved.
Show resolved Hide resolved
],
},
],
})),
minimum_should_match: 1,
},
},
aggs: {
perSloId: {
terms: {
size: sloIds.length,
field: 'kibana.alert.rule.parameters.sloId',
multi_terms: {
size: sloIdsAndInstanceIds.length,
terms: [{ field: 'slo.id' }, { field: 'slo.instanceId' }],
},
},
},
}),
signal,
});

return response.aggregations.perSloId.buckets.reduce(
(acc, bucket) => ({
...acc,
[bucket.key]: {
count: bucket.doc_count ?? 0,
} as ActiveAlerts,
}),
{}
);
const activeAlertsData = response.aggregations.perSloId.buckets.reduce((acc, bucket) => {
return { ...acc, [bucket.key_as_string]: bucket.doc_count ?? 0 };
}, {} as Record<string, number>);
return new ActiveAlerts(activeAlertsData);
} catch (error) {
// ignore error
}
Expand Down
@@ -0,0 +1,57 @@
/*
* 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 {
QueryObserverResult,
RefetchOptions,
RefetchQueryFilters,
useQuery,
} from '@tanstack/react-query';
import { SLOResponse } from '@kbn/slo-schema';
import { useKibana } from '../../utils/kibana_react';

export interface UseFetchSloDefinitionsResponse {
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
data: SLOResponse[] | undefined;
refetch: <TPageData>(
options?: (RefetchOptions & RefetchQueryFilters<TPageData>) | undefined
) => Promise<QueryObserverResult<SLOResponse[], unknown>>;
}

interface Params {
name?: string;
size?: number;
}

export function useFetchSloDefinitions({
name = '',
size = 10,
}: Params): UseFetchSloDefinitionsResponse {
const { savedObjects } = useKibana().services;
const search = name.endsWith('*') ? name : `${name}*`;

const { isLoading, isError, isSuccess, data, refetch } = useQuery({
queryKey: ['fetchSloDefinitions', search],
queryFn: async () => {
try {
const response = await savedObjects.client.find<SLOResponse>({
simianhacker marked this conversation as resolved.
Show resolved Hide resolved
type: 'slo',
search,
searchFields: ['name'],
perPage: size,
});
return response.savedObjects.map((so) => so.attributes);
} catch (error) {
throw new Error(`Something went wrong. Error: ${error}`);
}
},
});

return { isLoading, isError, isSuccess, data, refetch };
}