[SLO] Add burn rate windows to SLO detail page (#159750)

## Summary

This PR adds a burn rate visualization to the overview tab of the SLO
Detail page. This PR also includes a fix for fetching the index pattern
fields hook; it uses the DataViews service to fetch the fields instead
of the internal API.

<img width="1170" alt="image"
src="41057791-880e-4cc8-a0c7-02a0f18aaeca">

### All good

<img width="1141" alt="image"
src="3ec07efa-e35a-4251-87f3-7ddc836171b7">

### Degrading

<img width="1141" alt="image"
src="a6d347be-7b55-404e-99a1-14ad4a38ad36">

### EVERYTHING IS BURNING 🔥

<img width="1141" alt="image"
src="9ed05875-b907-4a57-8387-a094876dd35e">


### Recovering in the dark

<img width="1151" alt="image"
src="f2999c7a-f97b-474c-8146-4565445df892">

### No data

<img width="1141" alt="image"
src="675a65a4-91b1-4de3-9f51-b65760efbb66">
This commit is contained in:
Chris Cowan 2023-06-20 16:02:48 -06:00 committed by GitHub
parent 7153359cb8
commit c0d3a93dff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 534 additions and 28 deletions

View file

@ -22,6 +22,9 @@ import {
summarySchema,
tagsSchema,
timeWindowSchema,
apmTransactionErrorRateIndicatorSchema,
apmTransactionDurationIndicatorSchema,
durationType,
timeWindowTypeSchema,
} from '../schema';
@ -141,6 +144,28 @@ const getSLODiagnosisParamsSchema = t.type({
path: t.type({ id: t.string }),
});
const getSLOBurnRatesResponseSchema = t.type({
burnRates: t.array(
t.type({
name: t.string,
burnRate: t.number,
sli: t.number,
})
),
});
const getSLOBurnRatesParamsSchema = t.type({
path: t.type({ id: t.string }),
body: t.type({
windows: t.array(
t.type({
name: t.string,
duration: durationType,
})
),
}),
});
type SLOResponse = t.OutputOf<typeof sloResponseSchema>;
type SLOWithSummaryResponse = t.OutputOf<typeof sloWithSummaryResponseSchema>;
@ -166,6 +191,11 @@ type HistoricalSummaryResponse = t.OutputOf<typeof historicalSummarySchema>;
type GetPreviewDataParams = t.TypeOf<typeof getPreviewDataParamsSchema.props.body>;
type GetPreviewDataResponse = t.TypeOf<typeof getPreviewDataResponseSchema>;
type APMTransactionErrorRateIndicatorSchema = t.TypeOf<
typeof apmTransactionErrorRateIndicatorSchema
>;
type APMTransactionDurationIndicatorSchema = t.TypeOf<typeof apmTransactionDurationIndicatorSchema>;
type GetSLOBurnRatesResponse = t.OutputOf<typeof getSLOBurnRatesResponseSchema>;
type BudgetingMethod = t.TypeOf<typeof budgetingMethodSchema>;
type TimeWindow = t.TypeOf<typeof timeWindowTypeSchema>;
@ -190,6 +220,8 @@ export {
sloWithSummaryResponseSchema,
updateSLOParamsSchema,
updateSLOResponseSchema,
getSLOBurnRatesParamsSchema,
getSLOBurnRatesResponseSchema,
};
export type {
BudgetingMethod,
@ -210,6 +242,9 @@ export type {
UpdateSLOInput,
UpdateSLOParams,
UpdateSLOResponse,
APMTransactionDurationIndicatorSchema,
APMTransactionErrorRateIndicatorSchema,
GetSLOBurnRatesResponse,
Indicator,
MetricCustomIndicator,
KQLCustomIndicator,

View file

@ -33,6 +33,7 @@ export const sloKeys = {
historicalSummaries: () => [...sloKeys.all, 'historicalSummary'] as const,
historicalSummary: (sloIds: string[]) => [...sloKeys.historicalSummaries(), sloIds] as const,
globalDiagnosis: () => [...sloKeys.all, 'globalDiagnosis'] as const,
burnRates: (sloId: string) => [...sloKeys.all, 'burnRates', sloId] as const,
preview: (indicator?: Indicator) => [...sloKeys.all, 'preview', indicator] as const,
};

View file

@ -0,0 +1,76 @@
/*
* 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 { GetSLOBurnRatesResponse } from '@kbn/slo-schema';
import { useKibana } from '../../utils/kibana_react';
import { sloKeys } from './query_key_factory';
export interface UseFetchSloBurnRatesResponse {
isInitialLoading: boolean;
isLoading: boolean;
isRefetching: boolean;
isSuccess: boolean;
isError: boolean;
data: GetSLOBurnRatesResponse | undefined;
refetch: <TPageData>(
options?: (RefetchOptions & RefetchQueryFilters<TPageData>) | undefined
) => Promise<QueryObserverResult<GetSLOBurnRatesResponse | undefined, unknown>>;
}
const LONG_REFETCH_INTERVAL = 1000 * 60; // 1 minute
interface UseFetchSloBurnRatesParams {
sloId: string;
windows: Array<{ name: string; duration: string }>;
shouldRefetch?: boolean;
}
export function useFetchSloBurnRates({
sloId,
windows,
shouldRefetch,
}: UseFetchSloBurnRatesParams): UseFetchSloBurnRatesResponse {
const { http } = useKibana().services;
const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data, refetch } = useQuery(
{
queryKey: sloKeys.burnRates(sloId),
queryFn: async ({ signal }) => {
try {
const response = await http.post<GetSLOBurnRatesResponse>(
`/internal/observability/slos/${sloId}/_burn_rates`,
{
body: JSON.stringify({ windows }),
signal,
}
);
return response;
} catch (error) {
// ignore error
}
},
refetchInterval: shouldRefetch ? LONG_REFETCH_INTERVAL : undefined,
refetchOnWindowFocus: false,
keepPreviousData: true,
}
);
return {
data,
refetch,
isLoading,
isRefetching,
isInitialLoading,
isSuccess,
isError,
};
}

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 React from 'react';
import {
EuiSpacer,
EuiFlexGroup,
EuiPanel,
EuiFlexItem,
EuiStat,
EuiTextColor,
EuiText,
EuiIconTip,
} from '@elastic/eui';
import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
export interface BurnRateWindowParams {
title: string;
target: number;
longWindow: {
label: string;
burnRate: number | null;
sli: number | null;
};
shortWindow: {
label: string;
burnRate: number | null;
sli: number | null;
};
isLoading?: boolean;
size?: 'xxxs' | 'xxs' | 'xs' | 's' | 'm' | 'l';
}
const SUBDUED = 'subdued';
const DANGER = 'danger';
const SUCCESS = 'success';
const WARNING = 'warning';
function getColorBasedOnBurnRate(target: number, burnRate: number | null, sli: number | null) {
if (burnRate === null || sli === null || sli < 0) {
return SUBDUED;
}
if (burnRate > target) {
return DANGER;
}
return SUCCESS;
}
export function BurnRateWindow({
title,
target,
longWindow,
shortWindow,
isLoading,
size = 's',
}: BurnRateWindowParams) {
const longWindowColor = getColorBasedOnBurnRate(target, longWindow.burnRate, longWindow.sli);
const shortWindowColor = getColorBasedOnBurnRate(target, shortWindow.burnRate, shortWindow.sli);
const overallColor =
longWindowColor === DANGER && shortWindowColor === DANGER
? DANGER
: [longWindowColor, shortWindowColor].includes(DANGER)
? WARNING
: longWindowColor === SUBDUED && shortWindowColor === SUBDUED
? SUBDUED
: SUCCESS;
const isLongWindowValid =
longWindow.burnRate != null && longWindow.sli != null && longWindow.sli >= 0;
const isShortWindowValid =
shortWindow.burnRate != null && shortWindow.sli != null && shortWindow.sli >= 0;
return (
<EuiPanel color={overallColor}>
<EuiText color={overallColor}>
<h5>
{title}
<EuiIconTip
content={i18n.translate('xpack.observability.slo.burnRateWindow.thresholdTip', {
defaultMessage: 'Threshold is {target}x',
values: { target },
})}
position="top"
/>
</h5>
</EuiText>
<EuiSpacer size="xs" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiStat
title={isLongWindowValid ? `${numeral(longWindow.burnRate).format('0.[00]')}x` : '--'}
titleColor={longWindowColor}
titleSize={size}
textAlign="left"
isLoading={isLoading}
description={
<EuiTextColor color={longWindowColor}>
<span>{longWindow.label}</span>
</EuiTextColor>
}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiStat
title={isShortWindowValid ? `${numeral(shortWindow.burnRate).format('0.[00]')}x` : '--'}
titleColor={shortWindowColor}
titleSize={size}
textAlign="left"
isLoading={isLoading}
description={
<EuiTextColor color={shortWindowColor}>
<span>{shortWindow.label}</span>
</EuiTextColor>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}

View file

@ -0,0 +1,177 @@
/*
* 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 { GetSLOBurnRatesResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import {
EuiSpacer,
EuiFlexGrid,
EuiPanel,
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
EuiBetaBadge,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useFetchSloBurnRates } from '../../../hooks/slo/use_fetch_slo_burn_rates';
import { BurnRateWindow, BurnRateWindowParams } from './burn_rate_window';
interface Props {
slo: SLOWithSummaryResponse;
isAutoRefreshing?: boolean;
}
const CRITICAL_LONG = 'CRITICAL_LONG';
const CRITICAL_SHORT = 'CRITICAL_SHORT';
const HIGH_LONG = 'HIGH_LONG';
const HIGH_SHORT = 'HIGH_SHORT';
const MEDIUM_LONG = 'MEDIUM_LONG';
const MEDIUM_SHORT = 'MEDIUM_SHORT';
const LOW_LONG = 'LOW_LONG';
const LOW_SHORT = 'LOW_SHORT';
const WINDOWS = [
{ name: CRITICAL_LONG, duration: '1h' },
{ name: CRITICAL_SHORT, duration: '5m' },
{ name: HIGH_LONG, duration: '6h' },
{ name: HIGH_SHORT, duration: '30m' },
{ name: MEDIUM_LONG, duration: '24h' },
{ name: MEDIUM_SHORT, duration: '120m' },
{ name: LOW_LONG, duration: '72h' },
{ name: LOW_SHORT, duration: '360m' },
];
function getSliAndBurnRate(name: string, burnRates: GetSLOBurnRatesResponse['burnRates']) {
const data = burnRates.find((rate) => rate.name === name);
if (!data) {
return { burnRate: null, sli: null };
}
return { burnRate: data.burnRate, sli: data.sli };
}
export function BurnRates({ slo, isAutoRefreshing }: Props) {
const { isLoading, data } = useFetchSloBurnRates({
sloId: slo.id,
shouldRefetch: isAutoRefreshing,
windows: WINDOWS,
});
const criticalWindowParams: BurnRateWindowParams = {
title: i18n.translate('xpack.observability.slo.burnRate.criticalTitle', {
defaultMessage: 'Critical burn rate',
}),
target: 14.4,
longWindow: {
label: i18n.translate('xpack.observability.slo.burnRate.criticalLongLabel', {
defaultMessage: '1 hour',
}),
...getSliAndBurnRate(CRITICAL_LONG, data?.burnRates ?? []),
},
shortWindow: {
label: i18n.translate('xpack.observability.slo.burnRate.criticalShortLabel', {
defaultMessage: '5 minute',
}),
...getSliAndBurnRate(CRITICAL_SHORT, data?.burnRates ?? []),
},
};
const highWindowParams: BurnRateWindowParams = {
title: i18n.translate('xpack.observability.slo.burnRate.highTitle', {
defaultMessage: 'High burn rate',
}),
target: 6,
longWindow: {
label: i18n.translate('xpack.observability.slo.burnRate.highLongLabel', {
defaultMessage: '6 hour',
}),
...getSliAndBurnRate(HIGH_LONG, data?.burnRates ?? []),
},
shortWindow: {
label: i18n.translate('xpack.observability.slo.burnRate.highShortLabel', {
defaultMessage: '30 minute',
}),
...getSliAndBurnRate(HIGH_SHORT, data?.burnRates ?? []),
},
};
const mediumWindowParams: BurnRateWindowParams = {
title: i18n.translate('xpack.observability.slo.burnRate.mediumTitle', {
defaultMessage: 'Medium burn rate',
}),
target: 3,
longWindow: {
label: i18n.translate('xpack.observability.slo.burnRate.mediumLongLabel', {
defaultMessage: '24 hours',
}),
...getSliAndBurnRate(MEDIUM_LONG, data?.burnRates ?? []),
},
shortWindow: {
label: i18n.translate('xpack.observability.slo.burnRate.mediumShortLabel', {
defaultMessage: '2 hours',
}),
...getSliAndBurnRate(MEDIUM_SHORT, data?.burnRates ?? []),
},
};
const lowWindowParams: BurnRateWindowParams = {
title: i18n.translate('xpack.observability.slo.burnRate.lowTitle', {
defaultMessage: 'Low burn rate',
}),
target: 1,
longWindow: {
label: i18n.translate('xpack.observability.slo.burnRate.lowLongLabel', {
defaultMessage: '3 days',
}),
...getSliAndBurnRate(LOW_LONG, data?.burnRates ?? []),
},
shortWindow: {
label: i18n.translate('xpack.observability.slo.burnRate.lowShortLabel', {
defaultMessage: '6 hours',
}),
...getSliAndBurnRate(LOW_SHORT, data?.burnRates ?? []),
},
};
return (
<EuiPanel paddingSize="m" color="transparent" hasBorder data-test-subj="burnRatePanel">
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.observability.slo.burnRate.title', {
defaultMessage: 'Burn rate windows',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={0}>
<EuiBetaBadge
label={i18n.translate('xpack.observability.slo.burnRate.technicalPreviewBadgeTitle', {
defaultMessage: 'Technical Preview',
})}
size="s"
tooltipPosition="bottom"
tooltipContent={i18n.translate(
'xpack.observability.slo.burnRate.technicalPreviewBadgeDescription',
{
defaultMessage:
'This functionality is in technical preview and is subject to change or may be removed in future versions. The design and code is less mature than official generally available features and is being provided as-is with no warranties. Technical preview features are not subject to the support service level agreement of official generally available features.',
}
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGrid columns={4}>
<BurnRateWindow {...criticalWindowParams} isLoading={isLoading} />
<BurnRateWindow {...highWindowParams} isLoading={isLoading} />
<BurnRateWindow {...mediumWindowParams} isLoading={isLoading} />
<BurnRateWindow {...lowWindowParams} isLoading={isLoading} />
</EuiFlexGrid>
</EuiPanel>
);
}

View file

@ -0,0 +1,46 @@
/*
* 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 } from '@elastic/eui';
import React, { Fragment } from 'react';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useKibana } from '../../../utils/kibana_react';
const ALERTS_TABLE_ID = 'xpack.observability.slo.sloDetails.alertTable';
export interface Props {
slo: SLOWithSummaryResponse;
}
export function SloDetailsAlerts({ slo }: Props) {
const {
triggersActionsUi: { alertsTableConfigurationRegistry, getAlertsStateTable: AlertsStateTable },
} = useKibana().services;
return (
<Fragment>
<EuiSpacer size="l" />
<EuiFlexGroup direction="column" gutterSize="xl">
<EuiFlexItem>
<AlertsStateTable
alertsTableConfigurationRegistry={alertsTableConfigurationRegistry}
configurationId={AlertConsumers.OBSERVABILITY}
id={ALERTS_TABLE_ID}
flyoutSize="s"
data-test-subj="alertTable"
featureIds={[AlertConsumers.SLO]}
query={{ bool: { filter: { term: { 'slo.id': slo.id } } } }}
showExpandToDetails={false}
showAlertStatusWithFlapping
pageSize={100}
/>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
);
}

View file

@ -15,30 +15,25 @@ import {
} from '@elastic/eui';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import React, { Fragment } from 'react';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { i18n } from '@kbn/i18n';
import { useFetchActiveAlerts } from '../../../hooks/slo/use_fetch_active_alerts';
import { useKibana } from '../../../utils/kibana_react';
import { formatHistoricalData } from '../../../utils/slo/chart_data_formatter';
import { useFetchHistoricalSummary } from '../../../hooks/slo/use_fetch_historical_summary';
import { ErrorBudgetChartPanel } from './error_budget_chart_panel';
import { Overview as Overview } from './overview';
import { SliChartPanel } from './sli_chart_panel';
import { SloDetailsAlerts } from './slo_detail_alerts';
import { BurnRates } from './burn_rates';
export interface Props {
slo: SLOWithSummaryResponse;
isAutoRefreshing: boolean;
}
const ALERTS_TABLE_ID = 'xpack.observability.slo.sloDetails.alertTable';
const OVERVIEW_TAB = 'overview';
const ALERTS_TAB = 'alerts';
export function SloDetails({ slo, isAutoRefreshing }: Props) {
const {
triggersActionsUi: { alertsTableConfigurationRegistry, getAlertsStateTable: AlertsStateTable },
} = useKibana().services;
const { data: activeAlerts } = useFetchActiveAlerts({
sloIds: [slo.id],
});
@ -66,6 +61,9 @@ export function SloDetails({ slo, isAutoRefreshing }: Props) {
<Overview slo={slo} />
</EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexItem>
<BurnRates slo={slo} isAutoRefreshing={isAutoRefreshing} />
</EuiFlexItem>
<EuiFlexItem>
<SliChartPanel
data={historicalSliData}
@ -96,27 +94,7 @@ export function SloDetails({ slo, isAutoRefreshing }: Props) {
{(activeAlerts && activeAlerts[slo.id]?.count) ?? 0}
</EuiNotificationBadge>
),
content: (
<Fragment>
<EuiSpacer size="l" />
<EuiFlexGroup direction="column" gutterSize="xl">
<EuiFlexItem>
<AlertsStateTable
alertsTableConfigurationRegistry={alertsTableConfigurationRegistry}
configurationId={AlertConsumers.OBSERVABILITY}
id={ALERTS_TABLE_ID}
flyoutSize="s"
data-test-subj="alertTable"
featureIds={[AlertConsumers.SLO]}
query={{ bool: { filter: { term: { 'slo.id': slo.id } } } }}
showExpandToDetails={false}
showAlertStatusWithFlapping
pageSize={100}
/>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
),
content: <SloDetailsAlerts slo={slo} />,
},
];

View file

@ -11,6 +11,7 @@ import {
deleteSLOParamsSchema,
fetchHistoricalSummaryParamsSchema,
findSLOParamsSchema,
getSLOBurnRatesParamsSchema,
getPreviewDataParamsSchema,
getSLODiagnosisParamsSchema,
getSLOParamsSchema,
@ -42,6 +43,7 @@ import type { IndicatorTypes } from '../../domain/models';
import type { ObservabilityRequestHandlerContext } from '../../types';
import { ManageSLO } from '../../services/slo/manage_slo';
import { getGlobalDiagnosis, getSloDiagnosis } from '../../services/slo/get_diagnosis';
import { getBurnRates } from '../../services/slo/get_burn_rates';
import { GetPreviewData } from '../../services/slo/get_preview_data';
const transformGenerators: Record<IndicatorTypes, TransformGenerator> = {
@ -305,6 +307,29 @@ const getSloDiagnosisRoute = createObservabilityServerRoute({
},
});
const getSloBurnRates = createObservabilityServerRoute({
endpoint: 'POST /internal/observability/slos/{id}/_burn_rates',
options: {
tags: ['access:slo_read'],
},
params: getSLOBurnRatesParamsSchema,
handler: async ({ context, params }) => {
const hasCorrectLicense = await isLicenseAtLeastPlatinum(context);
if (!hasCorrectLicense) {
throw badRequest('Platinum license or higher is needed to make use of this feature.');
}
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const soClient = (await context.core).savedObjects.client;
const burnRates = await getBurnRates(params.path.id, params.body.windows, {
soClient,
esClient,
});
return { burnRates };
},
});
const getPreviewData = createObservabilityServerRoute({
endpoint: 'POST /internal/observability/slos/_preview',
options: {
@ -335,5 +360,6 @@ export const sloRouteRepository = {
...updateSLORoute,
...getDiagnosisRoute,
...getSloDiagnosisRoute,
...getSloBurnRates,
...getPreviewData,
};

View file

@ -0,0 +1,40 @@
/*
* 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 { KibanaSavedObjectsSLORepository } from './slo_repository';
import { DefaultSLIClient } from './sli_client';
import { Duration } from '../../domain/models';
import { computeSLI, computeBurnRate } from '../../domain/services';
interface Services {
soClient: SavedObjectsClientContract;
esClient: ElasticsearchClient;
}
interface LookbackWindow {
name: string;
duration: Duration;
}
export async function getBurnRates(sloId: string, windows: LookbackWindow[], services: Services) {
const { soClient, esClient } = services;
const repository = new KibanaSavedObjectsSLORepository(soClient);
const sliClient = new DefaultSLIClient(esClient);
const slo = await repository.findById(sloId);
const sliData = await sliClient.fetchSLIDataFrom(slo, windows);
return Object.keys(sliData).map((key) => {
return {
name: key,
burnRate: computeBurnRate(slo, sliData[key]),
sli: computeSLI(sliData[key].good, sliData[key].total),
};
});
}