Skip to content

Commit

Permalink
[SLO] Allow users to define burn rate windows using budget consumed (#…
Browse files Browse the repository at this point in the history
…170996)

## Summary

This PR fixes #170854 and
#170859 by allowing the user to
define the burn rate windows using either a burn rate threshold or the %
of budget consumed by the time the alert triggers. This PR also adds the
budget consumed to the help text below the burn rate window definition.
I also changed the behavior to hide the window definitions until the
Selected SLO is chosen since we need the SLO for the calculations.


https://github.com/elastic/kibana/assets/41702/26f795f3-47cb-45c6-8ba3-528c2d9c4094
  • Loading branch information
simianhacker authored Nov 11, 2023
1 parent b704db8 commit 7923a96
Show file tree
Hide file tree
Showing 9 changed files with 199 additions and 60 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* 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 { EuiFieldNumber, EuiFormRow, EuiIconTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { ChangeEvent, useState } from 'react';
import numeral from '@elastic/numeral';

interface Props {
initialBurnRate?: number;
errors?: string[];
onChange: (burnRate: number) => void;
longLookbackWindowInHours: number;
sloTimeWindowInHours: number;
}

export function BudgetConsumed({
onChange,
initialBurnRate = 1,
longLookbackWindowInHours,
sloTimeWindowInHours,
errors,
}: Props) {
const [budgetConsumed, setBudgetConsumed] = useState<number>(
((initialBurnRate * longLookbackWindowInHours) / sloTimeWindowInHours) * 100
);
const hasError = errors !== undefined && errors.length > 0;

const onBudgetConsumedChanged = (event: ChangeEvent<HTMLInputElement>) => {
const value = Number(event.target.value);
setBudgetConsumed(value);
const burnRate = sloTimeWindowInHours * (value / 100 / longLookbackWindowInHours);
onChange(burnRate);
};

return (
<EuiFormRow
label={
<>
{i18n.translate('xpack.observability.slo.rules.budgetConsumed.rowLabel', {
defaultMessage: '% Budget consumed',
})}{' '}
<EuiIconTip
position="top"
content={i18n.translate('xpack.observability.slo.rules.budgetConsumed.tooltip', {
defaultMessage: 'How much budget is consumed before the first alert is fired.',
})}
/>
</>
}
fullWidth
isInvalid={hasError}
>
<EuiFieldNumber
fullWidth
step={0.01}
min={0.01}
max={100}
value={numeral(budgetConsumed).format('0[.0]')}
onChange={(event) => onBudgetConsumedChanged(event)}
data-test-subj="budgetConsumed"
/>
</EuiFormRow>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
import { EuiFieldNumber, EuiFormRow, EuiIconTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { ChangeEvent, useState } from 'react';
import numeral from '@elastic/numeral';

interface Props {
initialBurnRate?: number;
maxBurnRate: number;
errors?: string[];
onChange: (burnRate: number) => void;
helpText?: string;
}

export function BurnRate({ onChange, initialBurnRate = 1, maxBurnRate, errors }: Props) {
Expand Down Expand Up @@ -48,10 +48,10 @@ export function BurnRate({ onChange, initialBurnRate = 1, maxBurnRate, errors }:
>
<EuiFieldNumber
fullWidth
step={0.1}
min={1}
step={0.01}
min={0.01}
max={maxBurnRate}
value={String(burnRate)}
value={numeral(burnRate).format('0[.0]')}
onChange={(event) => onBurnRateChange(event)}
data-test-subj="burnRate"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,21 +118,14 @@ export function BurnRateRuleEditor(props: Props) {
</>
)}
<EuiSpacer size="l" />
<EuiTitle size="xs">
<h5>
{i18n.translate('xpack.observability.burnRateRuleEditor.h5.defineMultipleBurnRateLabel', {
defaultMessage: 'Define multiple burn rate windows',
})}
</h5>
</EuiTitle>
<EuiSpacer size="s" />
<Windows
slo={selectedSlo}
windows={windowDefs}
onChange={setWindowDefs}
errors={errors.windows}
/>
<EuiSpacer size="m" />
{selectedSlo && (
<Windows
slo={selectedSlo}
windows={windowDefs}
onChange={setWindowDefs}
errors={errors.windows}
/>
)}
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe('ValidateBurnRateRule', () => {
expect(errors.windows[0].burnRateThreshold).toHaveLength(1);
});

it('validates burnRateThreshold is between 1 and maxBurnRateThreshold', () => {
it('validates burnRateThreshold is between 0.01 and maxBurnRateThreshold', () => {
expect(
validateBurnRateRule(
createTestParams({
Expand All @@ -55,7 +55,7 @@ describe('ValidateBurnRateRule', () => {
).toHaveLength(1);

expect(
validateBurnRateRule(createTestParams({ burnRateThreshold: 0.99 })).errors.windows[0]
validateBurnRateRule(createTestParams({ burnRateThreshold: 0.001 })).errors.windows[0]
.burnRateThreshold
).toHaveLength(1);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function validateBurnRateRule(
const result = { longWindow: new Array<string>(), burnRateThreshold: new Array<string>() };
if (burnRateThreshold === undefined || maxBurnRateThreshold === undefined) {
result.burnRateThreshold.push(BURN_RATE_THRESHOLD_REQUIRED);
} else if (sloId && (burnRateThreshold < 1 || burnRateThreshold > maxBurnRateThreshold)) {
} else if (sloId && (burnRateThreshold < 0.01 || burnRateThreshold > maxBurnRateThreshold)) {
result.burnRateThreshold.push(getInvalidThresholdValueError(maxBurnRateThreshold));
}
if (longWindow === undefined) {
Expand Down Expand Up @@ -89,6 +89,6 @@ const BURN_RATE_THRESHOLD_REQUIRED = i18n.translate(

const getInvalidThresholdValueError = (maxBurnRate: number) =>
i18n.translate('xpack.observability.slo.rules.burnRate.errors.invalidThresholdValue', {
defaultMessage: 'Burn rate threshold must be between 1 and {maxBurnRate}.',
defaultMessage: 'Burn rate threshold must be between 0.01 and {maxBurnRate}.',
values: { maxBurnRate },
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import React, { useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
Expand All @@ -14,6 +14,8 @@ import {
EuiSelect,
EuiFormRow,
EuiText,
EuiTitle,
EuiSwitch,
} from '@elastic/eui';
import { SLOResponse } from '@kbn/slo-schema';
import { i18n } from '@kbn/i18n';
Expand All @@ -31,13 +33,15 @@ import {
MEDIUM_PRIORITY_ACTION,
} from '../../../common/constants';
import { WindowResult } from './validation';
import { BudgetConsumed } from './budget_consumed';

interface WindowProps extends WindowSchema {
slo?: SLOResponse;
onChange: (windowDef: WindowSchema) => void;
onDelete: (id: string) => void;
disableDelete: boolean;
errors: WindowResult;
budgetMode: boolean;
}

const ACTION_GROUP_OPTIONS = [
Expand Down Expand Up @@ -65,6 +69,7 @@ function Window({
onDelete,
errors,
disableDelete,
budgetMode = false,
}: WindowProps) {
const onLongWindowDurationChange = (duration: Duration) => {
const longWindowDurationInMinutes = toMinutes(duration);
Expand Down Expand Up @@ -112,6 +117,17 @@ function Window({
return 'N/A';
};

const sloTimeWindowInHours = Math.round(
toMinutes(toDuration(slo?.timeWindow.duration ?? '30d')) / 60
);

const computeBudgetConsumed = () => {
if (slo && longWindow.value > 0 && burnRateThreshold > 0) {
return (burnRateThreshold * longWindow.value) / sloTimeWindowInHours;
}
return 0;
};

const allErrors = [...errors.longWindow, ...errors.burnRateThreshold];

return (
Expand All @@ -125,15 +141,27 @@ function Window({
errors={errors.longWindow}
/>
</EuiFlexItem>
<EuiFlexItem>
<BurnRate
initialBurnRate={burnRateThreshold}
maxBurnRate={maxBurnRateThreshold}
onChange={onBurnRateChange}
errors={errors.burnRateThreshold}
helpText={getErrorBudgetExhaustionText(computeErrorBudgetExhaustionInHours())}
/>
</EuiFlexItem>
{!budgetMode && (
<EuiFlexItem>
<BurnRate
initialBurnRate={burnRateThreshold}
maxBurnRate={maxBurnRateThreshold}
onChange={onBurnRateChange}
errors={errors.burnRateThreshold}
/>
</EuiFlexItem>
)}
{budgetMode && (
<EuiFlexItem>
<BudgetConsumed
initialBurnRate={burnRateThreshold}
onChange={onBurnRateChange}
errors={errors.burnRateThreshold}
sloTimeWindowInHours={sloTimeWindowInHours}
longLookbackWindowInHours={longWindow.value}
/>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiFormRow
label={i18n.translate('xpack.observability.slo.rules.actionGroupSelectorLabel', {
Expand Down Expand Up @@ -177,20 +205,43 @@ function Window({
</EuiText>
)}
<EuiText color="subdued" size="xs">
<p>{getErrorBudgetExhaustionText(computeErrorBudgetExhaustionInHours())}</p>
<p>
{getErrorBudgetExhaustionText(
computeErrorBudgetExhaustionInHours(),
computeBudgetConsumed(),
burnRateThreshold,
budgetMode
)}
</p>
</EuiText>
<EuiSpacer size="s" />
</>
);
}

const getErrorBudgetExhaustionText = (formattedHours: string) =>
i18n.translate('xpack.observability.slo.rules.errorBudgetExhaustion.text', {
defaultMessage: '{formatedHours} hours until error budget exhaustion.',
values: {
formatedHours: formattedHours,
},
});
const getErrorBudgetExhaustionText = (
formattedHours: string,
budgetConsumed: number,
burnRateThreshold: number,
budgetMode = false
) =>
budgetMode
? i18n.translate('xpack.observability.slo.rules.errorBudgetExhaustion.budgetMode.text', {
defaultMessage:
'{formatedHours} hours until error budget exhaustion. The burn rate threshold is {burnRateThreshold}x.',
values: {
formatedHours: formattedHours,
burnRateThreshold: numeral(burnRateThreshold).format('0[.0]'),
},
})
: i18n.translate('xpack.observability.slo.rules.errorBudgetExhaustion.burnRateMode.text', {
defaultMessage:
'{formatedHours} hours until error budget exhaustion. {budgetConsumed} budget consumed before first alert.',
values: {
formatedHours: formattedHours,
budgetConsumed: numeral(budgetConsumed).format('0.00%'),
},
});

export const createNewWindow = (
slo?: SLOResponse,
Expand All @@ -217,6 +268,7 @@ interface WindowsProps {
}

export function Windows({ slo, windows, errors, onChange, totalNumberOfWindows }: WindowsProps) {
const [budgetMode, setBudgetMode] = useState<boolean>(false);
const handleWindowChange = (windowDef: WindowSchema) => {
onChange(windows.map((def) => (windowDef.id === def.id ? windowDef : def)));
};
Expand All @@ -229,8 +281,20 @@ export function Windows({ slo, windows, errors, onChange, totalNumberOfWindows }
onChange([...windows, createNewWindow()]);
};

const handleModeChange = () => {
setBudgetMode((previous) => !previous);
};

return (
<>
<EuiTitle size="xs">
<h5>
{i18n.translate('xpack.observability.burnRateRuleEditor.h5.defineMultipleBurnRateLabel', {
defaultMessage: 'Define multiple burn rate windows',
})}
</h5>
</EuiTitle>
<EuiSpacer size="s" />
{windows.map((windowDef, index) => {
const windowErrors = errors[index] || {
longWindow: new Array<string>(),
Expand All @@ -245,25 +309,41 @@ export function Windows({ slo, windows, errors, onChange, totalNumberOfWindows }
onChange={handleWindowChange}
onDelete={handleWindowDelete}
disableDelete={windows.length === 1}
budgetMode={budgetMode}
/>
);
})}
<EuiButtonEmpty
data-test-subj="sloBurnRateRuleAddWindowButton"
color={'primary'}
size="xs"
iconType={'plusInCircleFilled'}
onClick={handleAddWindow}
isDisabled={windows.length === (totalNumberOfWindows || 4)}
aria-label={i18n.translate('xpack.observability.slo.rules.addWindowAriaLabel', {
defaultMessage: 'Add window',
})}
>
<FormattedMessage
id="xpack.observability.slo.rules.addWIndowLabel"
defaultMessage="Add window"
/>
</EuiButtonEmpty>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={0}>
<EuiButtonEmpty
data-test-subj="sloBurnRateRuleAddWindowButton"
color={'primary'}
size="s"
iconType={'plusInCircleFilled'}
onClick={handleAddWindow}
isDisabled={windows.length === (totalNumberOfWindows || 4)}
aria-label={i18n.translate('xpack.observability.slo.rules.addWindowAriaLabel', {
defaultMessage: 'Add window',
})}
>
<FormattedMessage
id="xpack.observability.slo.rules.addWIndowLabel"
defaultMessage="Add window"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={0}>
<EuiSwitch
compressed
onChange={handleModeChange}
checked={budgetMode}
label={i18n.translate('xpack.observability.slo.rules.useBudgetConsumedModeLabel', {
defaultMessage: 'Budget consumed mode',
})}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
</>
);
}
Loading

0 comments on commit 7923a96

Please sign in to comment.