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] Allow users to define burn rate windows using budget consumed #170996

Merged
merged 7 commits into from Nov 11, 2023
@@ -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);
simianhacker marked this conversation as resolved.
Show resolved Hide resolved
};

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>
);
}
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
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}
/>
)}
</>
);
}
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
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 },
});
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" />
</>
);
}