[SLOs] Filter out stale SLOs by default from overview (#182745)

## Summary

Fixes https://github.com/elastic/kibana/issues/176712

Added a way to define threshold to declare SLOs stale state , for now i
have chosen if it hasn't been updated in 48 hours, need product feedback
on this

<img width="1145" alt="image"
src="2ce11a60-2ed5-4929-a8ab-9d26d93f6956">

Added an overview panel to indicate how many SLOs are in stale state ,
user can click on each EuiState component to filter SLOs by


<img width="1725" alt="image"
src="352a893d-083d-4448-91bd-6737cfbe63f1">

Also added Alerts overview , clicking will take user to alerts or rules
page, pref filtered with burn rate

<img width="505" alt="image"
src="97387708-54e5-4c0d-9a09-d740e2087e79">
This commit is contained in:
Shahzad 2024-05-15 17:28:14 +02:00 committed by GitHub
parent 47cdb1ae6e
commit 3e9a8086d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 952 additions and 33 deletions

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import * as t from 'io-ts';
import { toBooleanRt } from '@kbn/io-ts-utils';
import { sloWithDataResponseSchema } from '../slo';
const sortDirectionSchema = t.union([t.literal('asc'), t.literal('desc')]);
@ -23,6 +24,7 @@ const findSLOParamsSchema = t.partial({
perPage: t.string,
sortBy: sortBySchema,
sortDirection: sortDirectionSchema,
hideStale: toBooleanRt,
}),
});

View file

@ -0,0 +1,35 @@
/*
* 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 * as t from 'io-ts';
const getOverviewParamsSchema = t.partial({
query: t.partial({
kqlQuery: t.string,
filters: t.string,
}),
});
const getOverviewResponseSchema = t.type({
violated: t.number,
degrading: t.number,
stale: t.number,
healthy: t.number,
worst: t.type({
value: t.number,
id: t.string,
}),
noData: t.number,
burnRateRules: t.number,
burnRateActiveAlerts: t.number,
burnRateRecoveredAlerts: t.number,
});
type GetOverviewParams = t.TypeOf<typeof getOverviewParamsSchema.props.query>;
type GetOverviewResponse = t.OutputOf<typeof getOverviewResponseSchema>;
export { getOverviewParamsSchema, getOverviewResponseSchema };
export type { GetOverviewParams, GetOverviewResponse };

View file

@ -43,11 +43,16 @@ const statusSchema = t.union([
t.literal('VIOLATED'),
]);
const summarySchema = t.type({
status: statusSchema,
sliValue: t.number,
errorBudget: errorBudgetSchema,
});
const summarySchema = t.intersection([
t.type({
status: statusSchema,
sliValue: t.number,
errorBudget: errorBudgetSchema,
}),
t.partial({
summaryUpdatedAt: t.union([t.string, t.null]),
}),
]);
const groupingsSchema = t.record(t.string, t.union([t.string, t.number]));

View file

@ -10,6 +10,9 @@ import * as t from 'io-ts';
export const sloSettingsSchema = t.type({
useAllRemoteClusters: t.boolean,
selectedRemoteClusters: t.array(t.string),
staleThresholdInHours: t.number,
});
export const sloServerlessSettingsSchema = t.type({});
export const sloServerlessSettingsSchema = t.type({
staleThresholdInHours: t.number,
});

View file

@ -88,3 +88,6 @@ export const getSLOSummaryPipelineId = (sloId: string, sloRevision: number) =>
export const SYNTHETICS_INDEX_PATTERN = 'synthetics-*';
export const SYNTHETICS_DEFAULT_GROUPINGS = ['monitor.name', 'observer.geo.name', 'monitor.id'];
// in hours
export const DEFAULT_STALE_SLO_THRESHOLD_HOURS = 48;

View file

@ -6,13 +6,17 @@
*/
import { getListOfSloSummaryIndices } from './summary_indices';
import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from './constants';
import {
DEFAULT_STALE_SLO_THRESHOLD_HOURS,
SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
} from './constants';
describe('getListOfSloSummaryIndices', () => {
it('should return default index if disabled', function () {
const settings = {
useAllRemoteClusters: false,
selectedRemoteClusters: [],
staleThresholdInHours: DEFAULT_STALE_SLO_THRESHOLD_HOURS,
};
const result = getListOfSloSummaryIndices(settings, []);
expect(result).toBe(SLO_SUMMARY_DESTINATION_INDEX_PATTERN);
@ -22,6 +26,7 @@ describe('getListOfSloSummaryIndices', () => {
const settings = {
useAllRemoteClusters: true,
selectedRemoteClusters: [],
staleThresholdInHours: DEFAULT_STALE_SLO_THRESHOLD_HOURS,
};
const clustersByName = [
{ name: 'cluster1', isConnected: true },
@ -37,6 +42,7 @@ describe('getListOfSloSummaryIndices', () => {
const settings = {
useAllRemoteClusters: false,
selectedRemoteClusters: ['cluster1'],
staleThresholdInHours: DEFAULT_STALE_SLO_THRESHOLD_HOURS,
};
const clustersByName = [
{ name: 'cluster1', isConnected: true },

View file

@ -97,6 +97,11 @@ get:
enum: [asc, desc]
default: asc
example: asc
- name: hideStale
in: query
description: Hide stale SLOs from the list as defined by stale SLO threshold in SLO settings
schema:
type: boolean
responses:
'200':
description: Successful request

View file

@ -27,12 +27,19 @@ interface SloGroupListFilter {
groupsFilter?: string[];
}
interface SLOOverviewFilter {
kqlQuery: string;
filters: string;
lastRefresh?: number;
}
export const sloKeys = {
all: ['slo'] as const,
lists: () => [...sloKeys.all, 'list'] as const,
list: (filters: SloListFilter) => [...sloKeys.lists(), filters] as const,
group: (filters: SloGroupListFilter) => [...sloKeys.groups(), filters] as const,
groups: () => [...sloKeys.all, 'group'] as const,
overview: (filters: SLOOverviewFilter) => ['overview', filters] as const,
details: () => [...sloKeys.all, 'details'] as const,
detail: (sloId?: string) => [...sloKeys.details(), sloId] as const,
rules: () => [...sloKeys.all, 'rules'] as const,

View file

@ -0,0 +1,24 @@
/*
* 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 { observabilityPaths } from '@kbn/observability-plugin/common';
import rison from '@kbn/rison';
import { useKibana } from '../utils/kibana_react';
export const useAlertsUrl = () => {
const { basePath } = useKibana().services.http;
const kuery = 'kibana.alert.rule.rule_type_id:("slo.rules.burnRate")';
return (status?: 'active' | 'recovered') =>
`${basePath.prepend(observabilityPaths.alerts)}?_a=${rison.encode({
kuery,
rangeFrom: 'now-24h',
rangeTo: 'now',
status,
})}`;
};

View file

@ -103,6 +103,7 @@ export function useFetchSloList({
...(page !== undefined && { page }),
...(perPage !== undefined && { perPage }),
...(filters && { filters }),
hideStale: true,
},
signal,
});

View file

@ -17,22 +17,27 @@ import {
EuiButtonEmpty,
EuiButton,
EuiSpacer,
EuiFieldNumber,
} from '@elastic/eui';
import React, { useEffect, useState } from 'react';
import { useFetcher } from '@kbn/observability-shared-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { isEqual } from 'lodash';
import { DEFAULT_STALE_SLO_THRESHOLD_HOURS } from '../../../common/constants';
import { useGetSettings } from './use_get_settings';
import { usePutSloSettings } from './use_put_slo_settings';
export function SettingsForm() {
const [useAllRemoteClusters, setUseAllRemoteClusters] = useState(false);
const [selectedRemoteClusters, setSelectedRemoteClusters] = useState<string[]>([]);
const [staleThresholdInHours, setStaleThresholdInHours] = useState(
DEFAULT_STALE_SLO_THRESHOLD_HOURS
);
const { http } = useKibana().services;
const { data: currentSettings } = useGetSettings();
const { mutateAsync: updateSettings } = usePutSloSettings();
const { mutateAsync: updateSettings, isLoading: isUpdating } = usePutSloSettings();
const { data, loading } = useFetcher(() => {
return http?.get<Array<{ name: string }>>('/api/remote_clusters');
@ -42,6 +47,7 @@ export function SettingsForm() {
if (currentSettings) {
setUseAllRemoteClusters(currentSettings.useAllRemoteClusters);
setSelectedRemoteClusters(currentSettings.selectedRemoteClusters);
setStaleThresholdInHours(currentSettings.staleThresholdInHours);
}
}, [currentSettings]);
@ -50,6 +56,7 @@ export function SettingsForm() {
settings: {
useAllRemoteClusters,
selectedRemoteClusters,
staleThresholdInHours,
},
});
};
@ -119,18 +126,57 @@ export function SettingsForm() {
/>
</EuiFormRow>
<EuiSpacer size="m" />
</EuiDescribedFormGroup>
<EuiDescribedFormGroup
title={
<h3>
{i18n.translate('xpack.slo.settingsForm.h3.staleThresholdLabel', {
defaultMessage: 'Stale SLOs threshold',
})}
</h3>
}
description={
<p>
{i18n.translate('xpack.slo.settingsForm.select.staleThresholdLabel', {
defaultMessage:
'SLOs not updated within the defined stale threshold will be hidden by default from the overview list.',
})}
</p>
}
>
<EuiFormRow
label={i18n.translate('xpack.slo.settingsForm.euiFormRow.select.selectThresholdLabel', {
defaultMessage: 'Select threshold',
})}
>
<EuiFieldNumber
data-test-subj="sloSettingsFormFieldNumber"
value={staleThresholdInHours}
onChange={(evt) => {
setStaleThresholdInHours(Number(evt.target.value));
}}
append={i18n.translate('xpack.slo.settingsForm.euiFormRow.select.hours', {
defaultMessage: 'Hours',
})}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
isLoading={loading}
isLoading={loading || isUpdating}
data-test-subj="o11ySettingsFormCancelButton"
onClick={() => {
setUseAllRemoteClusters(currentSettings?.useAllRemoteClusters || false);
setSelectedRemoteClusters(currentSettings?.selectedRemoteClusters || []);
setStaleThresholdInHours(
currentSettings?.staleThresholdInHours ?? DEFAULT_STALE_SLO_THRESHOLD_HOURS
);
}}
isDisabled={isEqual(currentSettings, {
useAllRemoteClusters,
selectedRemoteClusters,
staleThresholdInHours,
})}
>
{i18n.translate('xpack.slo.settingsForm.euiButtonEmpty.cancelLabel', {
@ -140,7 +186,7 @@ export function SettingsForm() {
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
isLoading={loading}
isLoading={loading || isUpdating}
data-test-subj="o11ySettingsFormSaveButton"
color="primary"
fill
@ -148,6 +194,7 @@ export function SettingsForm() {
isDisabled={isEqual(currentSettings, {
useAllRemoteClusters,
selectedRemoteClusters,
staleThresholdInHours,
})}
>
{i18n.translate('xpack.slo.settingsForm.applyButtonEmptyLabel', {

View file

@ -7,6 +7,7 @@
import { GetSLOSettingsResponse } from '@kbn/slo-schema';
import { useQuery } from '@tanstack/react-query';
import { DEFAULT_STALE_SLO_THRESHOLD_HOURS } from '../../../common/constants';
import { useKibana } from '../../utils/kibana_react';
export const useGetSettings = () => {
@ -30,4 +31,5 @@ export const useGetSettings = () => {
const defaultSettings: GetSLOSettingsResponse = {
useAllRemoteClusters: false,
selectedRemoteClusters: [],
staleThresholdInHours: DEFAULT_STALE_SLO_THRESHOLD_HOURS,
};

View file

@ -23,6 +23,7 @@ import {
} from '@kbn/presentation-util-plugin/public';
import { ALL_VALUE, HistoricalSummaryResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { Rule } from '@kbn/triggers-actions-ui-plugin/public';
import moment from 'moment';
import React, { useState } from 'react';
import { SloDeleteModal } from '../../../../components/slo/delete_confirmation_modal/slo_delete_confirmation_modal';
import { SloResetConfirmationModal } from '../../../../components/slo/reset_confirmation_modal/slo_reset_confirmation_modal';
@ -124,7 +125,17 @@ export function SloCardItem({ slo, rules, activeAlerts, historicalSummary, refet
overflow: hidden;
position: relative;
`}
title={slo.summary.status}
title={
slo.summary.summaryUpdatedAt
? i18n.translate('xpack.slo.sloCardItem.euiPanel.lastUpdatedLabel', {
defaultMessage: '{status}, Last updated: {value}',
values: {
status: slo.summary.status,
value: moment(slo.summary.summaryUpdatedAt).fromNow(),
},
})
: slo.summary.status
}
>
<SloCardChart
slo={slo}

View file

@ -6,10 +6,13 @@
*/
import React from 'react';
import { EuiCallOut } from '@elastic/eui';
import { EuiButtonEmpty, EuiCallOut } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useUrlSearchState } from '../hooks/use_url_search_state';
export function SloListEmpty() {
const { onStateChange } = useUrlSearchState();
return (
<EuiCallOut
title={i18n.translate('xpack.slo.list.emptyTitle', {
@ -21,6 +24,23 @@ export function SloListEmpty() {
{i18n.translate('xpack.slo.list.emptyMessage', {
defaultMessage: 'There are no results for your criteria.',
})}
<EuiButtonEmpty
data-test-subj="sloSloListEmptyLinkButtonButton"
onClick={() => {
onStateChange({
kqlQuery: '',
filters: [],
tagsFilter: undefined,
statusFilter: undefined,
});
}}
color="primary"
>
{i18n.translate('xpack.slo.sloListEmpty.clearFiltersButtonLabel', {
defaultMessage: 'Clear filters',
})}
</EuiButtonEmpty>
</EuiCallOut>
);
}

View file

@ -0,0 +1,56 @@
/*
* 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 { EuiFlexItem, EuiStat, EuiToolTip } from '@elastic/eui';
import React from 'react';
import { useUrlSearchState } from '../../hooks/use_url_search_state';
export function OverViewItem({
title,
description,
titleColor,
isLoading,
query,
tooltip,
onClick,
}: {
title?: string | number;
description: string;
titleColor: string;
isLoading: boolean;
query?: string;
tooltip?: string;
onClick?: () => void;
}) {
const { onStateChange } = useUrlSearchState();
return (
<EuiFlexItem grow={false}>
<EuiToolTip content={tooltip}>
<EuiStat
title={title}
description={description}
titleColor={titleColor}
reverse={true}
isLoading={isLoading}
onClick={() => {
if (onClick) {
onClick();
return;
}
onStateChange({
kqlQuery: query,
});
}}
css={{
cursor: 'pointer',
}}
/>
</EuiToolTip>
</EuiFlexItem>
);
}

View file

@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle, EuiPanel } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { GetOverviewResponse } from '@kbn/slo-schema/src/rest_specs/routes/get_overview';
import { rulesLocatorID, RulesParams } from '@kbn/observability-plugin/public';
import { useAlertsUrl } from '../../../../hooks/use_alerts_url';
import { useKibana } from '../../../../utils/kibana_react';
import { OverViewItem } from './overview_item';
export function SLOOverviewAlerts({
data,
isLoading,
}: {
data?: GetOverviewResponse;
isLoading: boolean;
}) {
const {
application,
share: {
url: { locators },
},
} = useKibana().services;
const locator = locators.get<RulesParams>(rulesLocatorID);
const getAlertsUrl = useAlertsUrl();
return (
<EuiPanel hasShadow={false} hasBorder={true}>
<EuiFlexGroup alignItems="center" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h3>
{i18n.translate('xpack.slo.sLOsOverview.h3.burnRateLabel', {
defaultMessage: 'Burn rate',
})}
</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued" size="s">
{i18n.translate('xpack.slo.sLOsOverview.lastTextLabel', {
defaultMessage: 'Last 24h',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xs" />
<EuiFlexGroup justifyContent="spaceBetween">
<OverViewItem
title={data?.burnRateActiveAlerts}
description={i18n.translate('xpack.slo.sLOsOverview.euiStat.burnRateActiveAlerts', {
defaultMessage: 'Active alerts',
})}
titleColor="danger"
isLoading={isLoading}
onClick={() => {
application.navigateToUrl(getAlertsUrl('active'));
}}
/>
<OverViewItem
title={data?.burnRateRecoveredAlerts}
description={i18n.translate('xpack.slo.sLOsOverview.euiStat.burnRateRecoveredAlerts', {
defaultMessage: 'Recovered alerts',
})}
titleColor="success"
isLoading={isLoading}
onClick={() => {
application.navigateToUrl(getAlertsUrl('recovered'));
}}
/>
<OverViewItem
title={data?.burnRateRules}
description={i18n.translate('xpack.slo.sLOsOverview.euiStat.burnRateRules', {
defaultMessage: 'Rules',
})}
titleColor="default"
isLoading={isLoading}
onClick={() => {
locator?.navigate({
type: ['slo.rules.burnRate'],
});
}}
/>
</EuiFlexGroup>
</EuiPanel>
);
}

View file

@ -0,0 +1,127 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiSpacer,
EuiTitle,
useEuiTheme,
} from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { DEFAULT_STALE_SLO_THRESHOLD_HOURS } from '../../../../../common/constants';
import { SLOOverviewAlerts } from './slo_overview_alerts';
import { useGetSettings } from '../../../slo_settings/use_get_settings';
import { useFetchSLOsOverview } from '../../hooks/use_fetch_slos_overview';
import { useUrlSearchState } from '../../hooks/use_url_search_state';
import { OverViewItem } from './overview_item';
export function SLOsOverview() {
const { state } = useUrlSearchState();
const { kqlQuery, filters, tagsFilter, statusFilter } = state;
const { data, isLoading } = useFetchSLOsOverview({
kqlQuery,
filters,
tagsFilter,
statusFilter,
});
const theme = useEuiTheme().euiTheme;
const { data: currentSettings } = useGetSettings();
return (
<EuiFlexGroup>
<EuiFlexItem grow={2}>
<EuiPanel hasShadow={false} hasBorder={true}>
<EuiTitle size="xs">
<h3>
{i18n.translate('xpack.slo.sLOsOverview.h3.overviewLabel', {
defaultMessage: 'Overview',
})}
</h3>
</EuiTitle>
<EuiSpacer size="xs" />
<EuiFlexGroup gutterSize="xl" justifyContent="spaceBetween">
<OverViewItem
title={data?.healthy}
description={i18n.translate('xpack.slo.sLOsOverview.euiStat.healthyLabel', {
defaultMessage: 'Healthy',
})}
titleColor="success"
isLoading={isLoading}
query={`status : HEALTHY`}
tooltip={i18n.translate('xpack.slo.sLOsOverview.euiStat.healthyLabel.tooltip', {
defaultMessage: 'Click to filter SLOs by Healthy status.',
})}
/>
<OverViewItem
title={data?.violated}
description={i18n.translate('xpack.slo.sLOsOverview.euiStat.violatedLabel', {
defaultMessage: 'Violated',
})}
titleColor="danger"
query={`status : VIOLATED`}
isLoading={isLoading}
tooltip={i18n.translate('xpack.slo.sLOsOverview.euiStat.violatedLabel.tooltip', {
defaultMessage: 'Click to filter SLOs by Violated status.',
})}
/>
<OverViewItem
title={data?.noData}
description={i18n.translate('xpack.slo.sLOsOverview.euiStat.noDataLabel', {
defaultMessage: 'No data',
})}
titleColor="subdued"
query={`status : NO_DATA`}
isLoading={isLoading}
tooltip={i18n.translate('xpack.slo.sLOsOverview.euiStat.noDataLabel.tooltip', {
defaultMessage: 'Click to filter SLOs by no data status.',
})}
/>
<OverViewItem
title={data?.degrading}
description={i18n.translate('xpack.slo.sLOsOverview.euiStat.degradingLabel', {
defaultMessage: 'Degrading',
})}
query={`status : DEGRADING`}
isLoading={isLoading}
tooltip={i18n.translate('xpack.slo.sLOsOverview.euiStat.degradingLabel.tooltip', {
defaultMessage: 'Click to filter SLOs by Degrading status.',
})}
titleColor={theme.colors.warningText}
/>
<OverViewItem
title={data?.stale}
description={i18n.translate('xpack.slo.sLOsOverview.euiStat.staleLabel', {
defaultMessage: 'Stale',
})}
titleColor="subdued"
isLoading={isLoading}
query={`summaryUpdatedAt < "now-${
currentSettings?.staleThresholdInHours ?? DEFAULT_STALE_SLO_THRESHOLD_HOURS
}h"`}
tooltip={i18n.translate('xpack.slo.sLOsOverview.euiStat.staleLabel.tooltip', {
defaultMessage:
'Click to filter SLOs which have not been updated in last {value} hour. They are filtered out by default from the list. Threshold can be changed in SLO settings.',
values: {
value:
currentSettings?.staleThresholdInHours ?? DEFAULT_STALE_SLO_THRESHOLD_HOURS,
},
})}
/>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={1}>
<SLOOverviewAlerts data={data} isLoading={isLoading} />
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import { buildQueryFromFilters, Filter } from '@kbn/es-query';
import { useMemo } from 'react';
import { GetOverviewResponse } from '@kbn/slo-schema/src/rest_specs/routes/get_overview';
import { sloKeys } from '../../../hooks/query_key_factory';
import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from '../../../../common/constants';
import { useCreateDataView } from '../../../hooks/use_create_data_view';
import { useKibana } from '../../../utils/kibana_react';
import { SearchState } from './use_url_search_state';
interface SLOsOverviewParams {
kqlQuery?: string;
tagsFilter?: SearchState['tagsFilter'];
statusFilter?: SearchState['statusFilter'];
filters?: Filter[];
lastRefresh?: number;
}
interface UseFetchSloGroupsResponse {
isLoading: boolean;
isRefetching: boolean;
isSuccess: boolean;
isError: boolean;
data: GetOverviewResponse | undefined;
}
export function useFetchSLOsOverview({
kqlQuery = '',
tagsFilter,
statusFilter,
filters: filterDSL = [],
lastRefresh,
}: SLOsOverviewParams = {}): UseFetchSloGroupsResponse {
const {
http,
notifications: { toasts },
} = useKibana().services;
const { dataView } = useCreateDataView({
indexPatternString: SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
});
const filters = useMemo(() => {
try {
return JSON.stringify(
buildQueryFromFilters(
[
...filterDSL,
...(tagsFilter ? [tagsFilter] : []),
...(statusFilter ? [statusFilter] : []),
],
dataView,
{
ignoreFilterIfFieldNotInIndex: true,
}
)
);
} catch (e) {
return '';
}
}, [filterDSL, tagsFilter, statusFilter, dataView]);
const { data, isLoading, isSuccess, isError, isRefetching } = useQuery({
queryKey: sloKeys.overview({
kqlQuery,
filters,
lastRefresh,
}),
queryFn: async ({ signal }) => {
return await http.get<GetOverviewResponse>('/internal/observability/slos/overview', {
query: {
...(kqlQuery && { kqlQuery }),
...(filters && { filters }),
},
signal,
});
},
cacheTime: 0,
refetchOnWindowFocus: false,
retry: (failureCount, error) => {
if (String(error) === 'Error: Forbidden') {
return false;
}
return failureCount < 4;
},
onError: (error: Error) => {
toasts.addError(error, {
title: i18n.translate('xpack.slo.overview.list.errorNotification', {
defaultMessage: 'Something went wrong while fetching SLOs overview',
}),
});
},
});
return {
data,
isLoading,
isSuccess,
isError,
isRefetching,
};
}

View file

@ -6,8 +6,10 @@
*/
import { i18n } from '@kbn/i18n';
import { useBreadcrumbs } from '@kbn/observability-shared-plugin/public';
import React, { useEffect } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { useBreadcrumbs } from '@kbn/observability-shared-plugin/public';
import { SLOsOverview } from './components/slos_overview/slos_overview';
import { paths } from '../../../common/locators/paths';
import { HeaderMenu } from '../../components/header_menu/header_menu';
import { SloOutdatedCallout } from '../../components/slo/slo_outdated_callout';
@ -66,6 +68,8 @@ export function SlosPage() {
>
<HeaderMenu />
<SloOutdatedCallout />
<SLOsOverview />
<EuiSpacer size="m" />
<SloList />
</ObservabilityPageTemplate>
);

View file

@ -17,7 +17,10 @@ import {
} from '@kbn/core/server';
import { PluginSetupContract, PluginStartContract } from '@kbn/alerting-plugin/server';
import { PluginSetupContract as FeaturesSetup } from '@kbn/features-plugin/server';
import { RuleRegistryPluginSetupContract } from '@kbn/rule-registry-plugin/server';
import {
RuleRegistryPluginSetupContract,
RuleRegistryPluginStartContract,
} from '@kbn/rule-registry-plugin/server';
import {
TaskManagerSetupContract,
TaskManagerStartContract,
@ -56,6 +59,7 @@ export interface PluginStart {
alerting: PluginStartContract;
taskManager: TaskManagerStartContract;
spaces?: SpacesPluginStart;
ruleRegistry: RuleRegistryPluginStartContract;
}
const sloRuleTypes = [SLO_BURN_RATE_RULE_TYPE_ID];
@ -153,6 +157,10 @@ export class SloPlugin implements Plugin<SloPluginSetup> {
const [, pluginStart] = await core.getStartServices();
return pluginStart.alerting.getRulesClientWithRequest(request);
},
getRacClientWithRequest: async (request) => {
const [, pluginStart] = await core.getStartServices();
return pluginStart.ruleRegistry.getRacClientWithRequest(request);
},
},
logger: this.logger,
repository: getSloServerRouteRepository({

View file

@ -8,7 +8,11 @@ import { errors } from '@elastic/elasticsearch';
import Boom from '@hapi/boom';
import { RulesClientApi } from '@kbn/alerting-plugin/server/types';
import { CoreSetup, KibanaRequest, Logger, RouteRegistrar } from '@kbn/core/server';
import { RuleDataPluginService } from '@kbn/rule-registry-plugin/server';
import {
AlertsClient,
RuleDataPluginService,
RuleRegistryPluginSetupContract,
} from '@kbn/rule-registry-plugin/server';
import {
decodeRequestParams,
parseEndpoint,
@ -33,10 +37,12 @@ interface RegisterRoutes {
export interface RegisterRoutesDependencies {
pluginsSetup: {
core: CoreSetup;
ruleRegistry: RuleRegistryPluginSetupContract;
};
getSpacesStart: () => Promise<SpacesPluginStart | undefined>;
ruleDataService: RuleDataPluginService;
getRulesClientWithRequest: (request: KibanaRequest) => Promise<RulesClientApi>;
getRacClientWithRequest: (request: KibanaRequest) => Promise<AlertsClient>;
}
export function registerRoutes({ config, repository, core, logger, dependencies }: RegisterRoutes) {

View file

@ -27,6 +27,8 @@ import {
resetSLOParamsSchema,
updateSLOParamsSchema,
} from '@kbn/slo-schema';
import { getOverviewParamsSchema } from '@kbn/slo-schema/src/rest_specs/routes/get_overview';
import { GetSLOsOverview } from '../../services/get_slos_overview';
import type { IndicatorTypes } from '../../domain/models';
import {
CreateSLO,
@ -663,6 +665,37 @@ const putSloSettings = (isServerless?: boolean) =>
},
});
const getSLOsOverview = createSloServerRoute({
endpoint: 'GET /internal/observability/slos/overview',
options: {
tags: ['access:slo_read'],
access: 'internal',
},
params: getOverviewParamsSchema,
handler: async ({ context, params, request, logger, dependencies }) => {
await assertPlatinumLicense(context);
const soClient = (await context.core).savedObjects.client;
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const racClient = await dependencies.getRacClientWithRequest(request);
const spaces = await dependencies.getSpacesStart();
const spaceId = (await spaces?.spacesService?.getActiveSpace(request))?.id ?? 'default';
const rulesClient = await dependencies.getRulesClientWithRequest(request);
const slosOverview = new GetSLOsOverview(
soClient,
esClient,
spaceId,
logger,
rulesClient,
racClient
);
return await slosOverview.execute(params?.query ?? {});
},
});
export const getSloRouteRepository = (isServerless?: boolean) => {
return {
...fetchSloHealthRoute,
@ -686,5 +719,6 @@ export const getSloRouteRepository = (isServerless?: boolean) => {
...resetSLORoute,
...findSLOGroupsRoute,
...getSLOSuggestionsRoute,
...getSLOsOverview,
};
};

View file

@ -1,5 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Summary Search Client returns the summary documents without duplicate temporary summary documents 1`] = `
Array [
Object {
@ -48,6 +49,7 @@ Object {
},
"sliValue": -1,
"status": "NO_DATA",
"summaryUpdatedAt": null,
},
},
Object {
@ -63,6 +65,7 @@ Object {
},
"sliValue": -1,
"status": "NO_DATA",
"summaryUpdatedAt": null,
},
},
Object {
@ -78,6 +81,7 @@ Object {
},
"sliValue": -1,
"status": "NO_DATA",
"summaryUpdatedAt": null,
},
},
Object {
@ -93,6 +97,7 @@ Object {
},
"sliValue": -1,
"status": "NO_DATA",
"summaryUpdatedAt": null,
},
},
Object {
@ -108,6 +113,7 @@ Object {
},
"sliValue": -1,
"status": "NO_DATA",
"summaryUpdatedAt": null,
},
},
],

View file

@ -45,6 +45,7 @@ describe('FindSLO', () => {
"page": 1,
"perPage": 25,
},
undefined,
]
`);
@ -139,6 +140,7 @@ describe('FindSLO', () => {
"page": 2,
"perPage": 10,
},
undefined,
]
`);
});

View file

@ -27,7 +27,8 @@ export class FindSLO {
params.kqlQuery ?? '',
params.filters ?? '',
toSort(params),
toPagination(params)
toPagination(params),
params.hideStale
);
const localSloDefinitions = await this.repository.findAllByIds(

View file

@ -12,10 +12,10 @@ import {
Pagination,
sloGroupWithSummaryResponseSchema,
} from '@kbn/slo-schema';
import { getListOfSummaryIndices, getSloSettings } from './slo_settings';
import { DEFAULT_SLO_GROUPS_PAGE_SIZE } from '../../common/constants';
import { IllegalArgumentError } from '../errors';
import { typedSearch } from '../utils/queries';
import { getListOfSummaryIndices } from './slo_settings';
import { EsSummaryDocument } from './summary_transform_generator/helpers/create_temp_summary';
import { getElasticsearchQueryOrThrow } from './transform_generators';
@ -56,8 +56,8 @@ export class FindSLOGroups {
} catch (e) {
this.logger.error(`Failed to parse filters: ${e.message}`);
}
const indices = await getListOfSummaryIndices(this.soClient, this.esClient);
const settings = await getSloSettings(this.soClient);
const { indices } = await getListOfSummaryIndices(this.esClient, settings);
const hasSelectedTags = groupBy === 'slo.tags' && groupsFilter.length > 0;

View file

@ -0,0 +1,153 @@
/*
* 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 { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { Logger } from '@kbn/logging';
import {
GetOverviewParams,
GetOverviewResponse,
} from '@kbn/slo-schema/src/rest_specs/routes/get_overview';
import { RulesClientApi } from '@kbn/alerting-plugin/server/types';
import { AlertsClient } from '@kbn/rule-registry-plugin/server';
import moment from 'moment';
import { observabilityAlertFeatureIds } from '@kbn/observability-plugin/common';
import { typedSearch } from '../utils/queries';
import { getElasticsearchQueryOrThrow } from './transform_generators';
import { getListOfSummaryIndices, getSloSettings } from './slo_settings';
export class GetSLOsOverview {
constructor(
private soClient: SavedObjectsClientContract,
private esClient: ElasticsearchClient,
private spaceId: string,
private logger: Logger,
private rulesClient: RulesClientApi,
private racClient: AlertsClient
) {}
public async execute(params: GetOverviewParams = {}): Promise<GetOverviewResponse> {
const settings = await getSloSettings(this.soClient);
const { indices } = await getListOfSummaryIndices(this.esClient, settings);
const kqlQuery = params.kqlQuery ?? '';
const filters = params.filters ?? '';
let parsedFilters: any = {};
try {
parsedFilters = JSON.parse(filters);
} catch (e) {
this.logger.error(`Failed to parse filters: ${e.message}`);
}
const response = await typedSearch(this.esClient, {
index: indices,
size: 0,
query: {
bool: {
filter: [
{ term: { spaceId: this.spaceId } },
getElasticsearchQueryOrThrow(kqlQuery),
...(parsedFilters.filter ?? []),
],
must_not: [...(parsedFilters.must_not ?? [])],
},
},
body: {
aggs: {
worst: {
top_hits: {
sort: {
errorBudgetRemaining: {
order: 'asc',
},
},
_source: {
includes: ['sliValue', 'status', 'slo.id', 'slo.instanceId', 'slo.name'],
},
size: 1,
},
},
stale: {
filter: {
range: {
summaryUpdatedAt: {
lt: `now-${settings.staleThresholdInHours}h`,
},
},
},
},
violated: {
filter: {
term: {
status: 'VIOLATED',
},
},
},
healthy: {
filter: {
term: {
status: 'HEALTHY',
},
},
},
degrading: {
filter: {
term: {
status: 'DEGRADING',
},
},
},
noData: {
filter: {
term: {
status: 'NO_DATA',
},
},
},
},
},
});
const [rules, alerts] = await Promise.all([
this.rulesClient.find({
options: {
search: 'alert.attributes.alertTypeId:("slo.rules.burnRate")',
},
}),
this.racClient.getAlertSummary({
featureIds: observabilityAlertFeatureIds,
gte: moment().subtract(24, 'hours').toISOString(),
lte: moment().toISOString(),
filter: [
{
term: {
'kibana.alert.rule.rule_type_id': 'slo.rules.burnRate',
},
},
],
}),
]);
const aggs = response.aggregations;
return {
violated: aggs?.violated.doc_count ?? 0,
degrading: aggs?.degrading.doc_count ?? 0,
healthy: aggs?.healthy.doc_count ?? 0,
noData: aggs?.noData.doc_count ?? 0,
stale: aggs?.stale.doc_count ?? 0,
worst: {
value: 0,
id: 'id',
},
burnRateRules: rules.total,
burnRateActiveAlerts: alerts.activeAlertCount,
burnRateRecoveredAlerts: alerts.recoveredAlertCount,
};
}
}

View file

@ -9,7 +9,10 @@ import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server';
import { PutSLOSettingsParams, sloSettingsSchema } from '@kbn/slo-schema';
import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from '../../common/constants';
import {
DEFAULT_STALE_SLO_THRESHOLD_HOURS,
SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
} from '../../common/constants';
import { getListOfSloSummaryIndices } from '../../common/summary_indices';
import { StoredSLOSettings } from '../domain/models';
import { sloSettingsObjectId, SO_SLO_SETTINGS_TYPE } from '../saved_objects/slo_settings';
@ -20,12 +23,15 @@ export const getSloSettings = async (soClient: SavedObjectsClientContract) => {
SO_SLO_SETTINGS_TYPE,
sloSettingsObjectId(soClient.getCurrentNamespace())
);
// set if it's not there
soObject.attributes.staleThresholdInHours = soObject.attributes.staleThresholdInHours ?? 2;
return sloSettingsSchema.encode(soObject.attributes);
} catch (e) {
if (SavedObjectsErrorHelpers.isNotFoundError(e)) {
return {
useAllRemoteClusters: false,
selectedRemoteClusters: [],
staleThresholdInHours: DEFAULT_STALE_SLO_THRESHOLD_HOURS,
};
}
throw e;
@ -49,15 +55,12 @@ export const storeSloSettings = async (
};
export const getListOfSummaryIndices = async (
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient
esClient: ElasticsearchClient,
settings: StoredSLOSettings
) => {
const indices: string[] = [SLO_SUMMARY_DESTINATION_INDEX_PATTERN];
const settings = await getSloSettings(soClient);
const { useAllRemoteClusters, selectedRemoteClusters } = settings;
if (!useAllRemoteClusters && selectedRemoteClusters.length === 0) {
return indices;
return { indices: [SLO_SUMMARY_DESTINATION_INDEX_PATTERN], settings };
}
const clustersByName = await esClient.cluster.remoteInfo();
@ -67,5 +70,5 @@ export const getListOfSummaryIndices = async (
isConnected: clustersByName[clusterName].connected,
}));
return getListOfSloSummaryIndices(settings, clusterInfo);
return { indices: getListOfSloSummaryIndices(settings, clusterInfo) };
};

View file

@ -121,4 +121,117 @@ describe('Summary Search Client', () => {
expect(results).toMatchSnapshot();
expect(results.total).toBe(5);
});
it('handles hideStale filter', async () => {
await service.search('', '', defaultSort, defaultPagination, true);
expect(esClientMock.search.mock.calls[0]).toEqual([
{
from: 0,
index: ['.slo-observability.summary-v3*'],
query: {
bool: {
filter: [
{
term: {
spaceId: 'default',
},
},
{
bool: {
should: [
{
term: {
isTempDoc: true,
},
},
{
range: {
summaryUpdatedAt: {
gte: 'now-2h',
},
},
},
],
},
},
{
match_all: {},
},
],
must_not: [],
},
},
size: 40,
sort: {
isTempDoc: {
order: 'asc',
},
sliValue: {
order: 'asc',
},
},
track_total_hits: true,
},
]);
await service.search('', '', defaultSort, defaultPagination);
expect(esClientMock.search.mock.calls[1]).toEqual([
{
from: 0,
index: ['.slo-observability.summary-v3*'],
query: {
bool: {
filter: [
{
term: {
spaceId: 'default',
},
},
{
match_all: {},
},
],
must_not: [],
},
},
size: 40,
sort: {
isTempDoc: {
order: 'asc',
},
sliValue: {
order: 'asc',
},
},
track_total_hits: true,
},
]);
});
it('handles summaryUpdate kql filter override', async () => {
await service.search('summaryUpdatedAt > now-2h', '', defaultSort, defaultPagination, true);
expect(esClientMock.search.mock.calls[0]).toEqual([
{
from: 0,
index: ['.slo-observability.summary-v3*'],
query: {
bool: {
filter: [
{ term: { spaceId: 'default' } },
{
bool: {
minimum_should_match: 1,
should: [{ range: { summaryUpdatedAt: { gt: 'now-2h' } } }],
},
},
],
must_not: [],
},
},
size: 40,
sort: { isTempDoc: { order: 'asc' }, sliValue: { order: 'asc' } },
track_total_hits: true,
},
]);
});
});

View file

@ -10,11 +10,12 @@ import { ElasticsearchClient, Logger, SavedObjectsClientContract } from '@kbn/co
import { ALL_VALUE, Paginated, Pagination } from '@kbn/slo-schema';
import { assertNever } from '@kbn/std';
import { partition } from 'lodash';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from '../../common/constants';
import { Groupings, SLODefinition, SLOId, Summary } from '../domain/models';
import { Groupings, SLODefinition, SLOId, StoredSLOSettings, Summary } from '../domain/models';
import { toHighPrecision } from '../utils/number';
import { createEsParams, typedSearch } from '../utils/queries';
import { getListOfSummaryIndices } from './slo_settings';
import { getListOfSummaryIndices, getSloSettings } from './slo_settings';
import { EsSummaryDocument } from './summary_transform_generator/helpers/create_temp_summary';
import { getElasticsearchQueryOrThrow } from './transform_generators';
import { fromRemoteSummaryDocumentToSloDefinition } from './unsafe_federated/remote_summary_doc_to_slo';
@ -44,7 +45,8 @@ export interface SummarySearchClient {
kqlQuery: string,
filters: string,
sort: Sort,
pagination: Pagination
pagination: Pagination,
hideStale?: boolean
): Promise<Paginated<SummaryResult>>;
}
@ -60,7 +62,8 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
kqlQuery: string,
filters: string,
sort: Sort,
pagination: Pagination
pagination: Pagination,
hideStale?: boolean
): Promise<Paginated<SummaryResult>> {
let parsedFilters: any = {};
@ -69,8 +72,8 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
} catch (e) {
this.logger.error(`Failed to parse filters: ${e.message}`);
}
const indices = await getListOfSummaryIndices(this.soClient, this.esClient);
const settings = await getSloSettings(this.soClient);
const { indices } = await getListOfSummaryIndices(this.esClient, settings);
const esParams = createEsParams({
index: indices,
track_total_hits: true,
@ -78,6 +81,7 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
bool: {
filter: [
{ term: { spaceId: this.spaceId } },
...excludeStaleSummaryFilter(settings, kqlQuery, hideStale),
getElasticsearchQueryOrThrow(kqlQuery),
...(parsedFilters.filter ?? []),
],
@ -159,6 +163,7 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
},
sliValue: toHighPrecision(doc._source.sliValue),
status: summaryDoc.status,
summaryUpdatedAt: summaryDoc.summaryUpdatedAt,
},
groupings: getFlattenedGroupings({
groupings: summaryDoc.slo.groupings,
@ -189,6 +194,32 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
}
}
function excludeStaleSummaryFilter(
settings: StoredSLOSettings,
kqlFilter: string,
hideStale?: boolean
): estypes.QueryDslQueryContainer[] {
if (kqlFilter.includes('summaryUpdatedAt') || !settings.staleThresholdInHours || !hideStale) {
return [];
}
return [
{
bool: {
should: [
{ term: { isTempDoc: true } },
{
range: {
summaryUpdatedAt: {
gte: `now-${settings.staleThresholdInHours}h`,
},
},
},
],
},
},
];
}
function getRemoteClusterName(index: string) {
if (index.includes(':')) {
return index.split(':')[0];