feat(slo): Burn rate alert (#147557)

This commit is contained in:
Kevin Delemme 2022-12-20 10:08:09 -05:00 committed by GitHub
parent 886289d206
commit 60867aacfb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 542 additions and 296 deletions

View file

@ -1,23 +0,0 @@
/*
* 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 { screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { render } from '../../../utils/test_helper';
import { BurnRate } from './burn_rate';
describe('BurnRate', () => {
it('shows error when entered burn rate exceed max burn rate', () => {
render(<BurnRate onChange={() => {}} maxBurnRate={20} />);
userEvent.type(screen.getByTestId('burnRate'), '1441', { delay: 0 });
expect(screen.getByText(/cannot exceed/i)).toBeTruthy();
});
});

View file

@ -7,24 +7,18 @@
import { EuiFieldNumber, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { ChangeEvent, useEffect, useState } from 'react';
import React, { ChangeEvent, useState } from 'react';
interface Props {
initialBurnRate?: number;
maxBurnRate: number;
errors?: string[];
onChange: (burnRate: number) => void;
}
export function BurnRate({ onChange, maxBurnRate }: Props) {
const [burnRate, setBurnRate] = useState<number>(1);
const [burnRateError, setBurnRateError] = useState<string | undefined>(undefined);
useEffect(() => {
if (burnRate > maxBurnRate) {
setBurnRateError(getErrorText(maxBurnRate));
} else {
setBurnRateError(undefined);
}
}, [burnRate, maxBurnRate]);
export function BurnRate({ onChange, initialBurnRate = 1, maxBurnRate, errors }: Props) {
const [burnRate, setBurnRate] = useState<number>(initialBurnRate);
const hasError = errors !== undefined && errors.length > 0;
const onBurnRateChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = Number(event.target.value);
@ -33,7 +27,12 @@ export function BurnRate({ onChange, maxBurnRate }: Props) {
};
return (
<EuiFormRow label={rowLabel} fullWidth isInvalid={!!burnRateError} error={burnRateError}>
<EuiFormRow
label={rowLabel}
fullWidth
isInvalid={hasError}
error={hasError ? errors[0] : undefined}
>
<EuiFieldNumber
fullWidth
step={0.1}
@ -50,9 +49,3 @@ export function BurnRate({ onChange, maxBurnRate }: Props) {
const rowLabel = i18n.translate('xpack.observability.slo.rules.burnRate.rowLabel', {
defaultMessage: 'Burn rate threshold',
});
const getErrorText = (maxBurnRate: number) =>
i18n.translate('xpack.observability.slo.rules.burnRate.errorText', {
defaultMessage: 'Burn rate cannot exceed {maxBurnRate}',
values: { maxBurnRate },
});

View file

@ -8,6 +8,7 @@
import React from 'react';
import { ComponentStory } from '@storybook/react';
import { BurnRateRuleParams } from '../../../typings';
import { BurnRateRuleEditor as Component } from './burn_rate_rule_editor';
export default {
@ -16,7 +17,13 @@ export default {
argTypes: {},
};
const Template: ComponentStory<typeof Component> = () => <Component />;
const Template: ComponentStory<typeof Component> = () => (
<Component
ruleParams={{} as BurnRateRuleParams}
setRuleParams={() => {}}
errors={{ sloId: [], longWindow: [], burnRateThreshold: [] }}
/>
);
const defaultProps = {};

View file

@ -5,54 +5,99 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSpacer } from '@elastic/eui';
import { assertNever } from '@kbn/std';
import numeral from '@elastic/numeral';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public';
import React, { useEffect, useState } from 'react';
import { Duration, SLO } from '../../../typings';
import { SloSelector } from '../../shared/slo/slo_selector/slo_selector';
import { i18n } from '@kbn/i18n';
import { toMinutes } from '../../../utils/slo/duration';
import { useFetchSloDetails } from '../../../hooks/slo/use_fetch_slo_details';
import { BurnRateRuleParams, Duration, DurationUnit, SLO } from '../../../typings';
import { SloSelector } from './slo_selector';
import { BurnRate } from './burn_rate';
import { LongWindowDuration } from './long_window_duration';
import { ValidationBurnRateRuleResult } from './validation';
type Props = Pick<
RuleTypeParamsExpressionProps<BurnRateRuleParams>,
'ruleParams' | 'setRuleParams'
> &
ValidationBurnRateRuleResult;
export function BurnRateRuleEditor(props: Props) {
const { setRuleParams, ruleParams, errors } = props;
const { loading: loadingInitialSlo, slo: initialSlo } = useFetchSloDetails(ruleParams?.sloId);
export function BurnRateRuleEditor() {
const [selectedSlo, setSelectedSlo] = useState<SLO | undefined>(undefined);
const [longWindowDuration, setLongWindowDuration] = useState<Duration>({ value: 1, unit: 'h' });
const [, setShortWindowDuration] = useState<Duration>({ value: 5, unit: 'm' });
const [, setBurnRate] = useState<number>(1);
const [maxBurnRate, setMaxBurnRate] = useState<number>(1);
const [longWindowDuration, setLongWindowDuration] = useState<Duration>({
value: ruleParams?.longWindow?.value ?? 1,
unit: (ruleParams?.longWindow?.unit as DurationUnit) ?? 'h',
});
const [shortWindowDuration, setShortWindowDuration] = useState<Duration>({
value: ruleParams?.shortWindow?.value ?? 5,
unit: (ruleParams?.shortWindow?.unit as DurationUnit) ?? 'm',
});
const [burnRate, setBurnRate] = useState<number>(ruleParams?.burnRateThreshold ?? 1);
const [maxBurnRate, setMaxBurnRate] = useState<number>(ruleParams?.maxBurnRateThreshold ?? 1);
useEffect(() => {
const hasInitialSlo = !loadingInitialSlo && initialSlo !== undefined;
setSelectedSlo(hasInitialSlo ? initialSlo : undefined);
}, [loadingInitialSlo, initialSlo, setRuleParams]);
const onLongWindowDurationChange = (duration: Duration) => {
setLongWindowDuration(duration);
const longWindowdurationInMinutes = toMinutes(duration);
const shortWindowDurationValue = Math.floor(longWindowdurationInMinutes / 12);
const longWindowDurationInMinutes = toMinutes(duration);
const shortWindowDurationValue = Math.floor(longWindowDurationInMinutes / 12);
setShortWindowDuration({ value: shortWindowDurationValue, unit: 'm' });
};
const onBurnRateChange = (value: number) => {
setBurnRate(value);
setRuleParams('burnRateThreshold', value);
};
const onSelectedSlo = (slo: SLO | undefined) => {
setSelectedSlo(slo);
setRuleParams('sloId', slo?.id);
};
useEffect(() => {
if (selectedSlo) {
const sloDurationInMinutes = toMinutes(selectedSlo.timeWindow.duration);
const longWindowDurationInMinutes = toMinutes(longWindowDuration);
setMaxBurnRate(Math.floor(sloDurationInMinutes / longWindowDurationInMinutes));
} else {
setMaxBurnRate(1);
const maxBurnRateThreshold = Math.floor(sloDurationInMinutes / longWindowDurationInMinutes);
setMaxBurnRate(maxBurnRateThreshold);
}
}, [longWindowDuration, selectedSlo]);
useEffect(() => {
setRuleParams('longWindow', longWindowDuration);
setRuleParams('shortWindow', shortWindowDuration);
}, [shortWindowDuration, longWindowDuration, setRuleParams]);
useEffect(() => {
setRuleParams('burnRateThreshold', burnRate);
setRuleParams('maxBurnRateThreshold', maxBurnRate);
}, [burnRate, maxBurnRate, setRuleParams]);
const computeErrorBudgetExhaustionInHours = () => {
if (selectedSlo && longWindowDuration?.value > 0 && burnRate >= 1) {
return numeral(
longWindowDuration.value /
((burnRate * toMinutes(longWindowDuration)) / toMinutes(selectedSlo.timeWindow.duration))
).format('0a');
}
return 'N/A';
};
return (
<EuiFlexGroup direction="column">
<EuiFlexGroup direction="row">
<EuiFlexItem>
<EuiFormRow label="Select SLO" fullWidth>
<SloSelector onSelected={onSelectedSlo} />
</EuiFormRow>
<SloSelector initialSlo={selectedSlo} onSelected={onSelectedSlo} errors={errors.sloId} />
</EuiFlexItem>
</EuiFlexGroup>
@ -60,34 +105,38 @@ export function BurnRateRuleEditor() {
<EuiFlexItem>
<LongWindowDuration
initialDuration={longWindowDuration}
shortWindowDuration={shortWindowDuration}
onChange={onLongWindowDurationChange}
errors={errors.longWindow}
/>
</EuiFlexItem>
<EuiFlexItem>
<BurnRate maxBurnRate={maxBurnRate} onChange={onBurnRateChange} />
<BurnRate
initialBurnRate={burnRate}
maxBurnRate={maxBurnRate}
onChange={onBurnRateChange}
errors={errors.burnRateThreshold}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup direction="row">
<EuiFlexItem>
<EuiText size="s" color="subdued">
{getErrorBudgetExhaustionText(computeErrorBudgetExhaustionInHours())}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
</EuiFlexGroup>
);
}
function toMinutes(duration: Duration) {
switch (duration.unit) {
case 'm':
return duration.value;
case 'h':
return duration.value * 60;
case 'd':
return duration.value * 24 * 60;
case 'w':
return duration.value * 7 * 24 * 60;
case 'M':
return duration.value * 30 * 24 * 60;
case 'Y':
return duration.value * 365 * 24 * 60;
}
assertNever(duration.unit);
}
const getErrorBudgetExhaustionText = (formatedHours: string) =>
i18n.translate('xpack.observability.slo.rules.errorBudgetExhaustion.text', {
defaultMessage:
"At this rate, the SLO's error budget will be exhausted after {formatedHours} hours.",
values: {
formatedHours,
},
});

View file

@ -1,45 +0,0 @@
/*
* 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 { screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { render } from '../../../utils/test_helper';
import { LongWindowDuration } from './long_window_duration';
describe('LongWindowDuration', () => {
it('shows error when duration is greater than 1440minutes', () => {
render(<LongWindowDuration onChange={() => {}} />);
userEvent.selectOptions(screen.getByTestId('durationUnitSelect'), 'm');
userEvent.clear(screen.getByTestId('durationValueInput'));
userEvent.type(screen.getByTestId('durationValueInput'), '1441', { delay: 0 });
expect(screen.getByText(/cannot exceed/i)).toBeTruthy();
});
it('shows error when duration is greater than 24 hours', () => {
render(<LongWindowDuration onChange={() => {}} />);
userEvent.selectOptions(screen.getByTestId('durationUnitSelect'), 'h');
userEvent.clear(screen.getByTestId('durationValueInput'));
userEvent.type(screen.getByTestId('durationValueInput'), '25', { delay: 0 });
expect(screen.getByText(/cannot exceed/i)).toBeTruthy();
});
it('shows error when duration is lower than 30 minutes', async () => {
render(<LongWindowDuration onChange={() => {}} />);
userEvent.selectOptions(screen.getByTestId('durationUnitSelect'), ['m']);
userEvent.clear(screen.getByTestId('durationValueInput'));
userEvent.type(screen.getByTestId('durationValueInput'), '29');
expect(screen.getByText(/cannot exceed/i)).toBeTruthy();
});
});

View file

@ -10,110 +10,80 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiSelect,
useGeneratedHtmlId,
EuiIcon,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { ChangeEvent, useEffect, useState } from 'react';
import React, { ChangeEvent, useState } from 'react';
import { toMinutes } from '../../../utils/slo/duration';
import { Duration, DurationUnit } from '../../../typings';
interface DurationUnitOption {
value: DurationUnit;
text: string;
}
const durationUnitOptions: DurationUnitOption[] = [
{ value: 'm', text: 'minute' },
{ value: 'h', text: 'hour' },
];
const MIN_DURATION_IN_MINUTES = 30;
const MAX_DURATION_IN_MINUTES = 1440;
const MAX_DURATION_IN_HOURS = 24;
import { Duration } from '../../../typings';
interface Props {
shortWindowDuration: Duration;
initialDuration?: Duration;
errors?: string[];
onChange: (duration: Duration) => void;
}
export function LongWindowDuration({ initialDuration, onChange }: Props) {
export function LongWindowDuration({
shortWindowDuration,
initialDuration,
onChange,
errors,
}: Props) {
const [durationValue, setDurationValue] = useState<number>(initialDuration?.value ?? 1);
const [durationUnit, setDurationUnit] = useState<DurationUnit>(
initialDuration?.unit ?? durationUnitOptions[0].value
);
const [error, setError] = useState<string | undefined>(undefined);
const hasError = errors !== undefined && errors.length > 0;
const onDurationValueChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
setDurationValue(!isNaN(value) ? value : 1);
onChange({ value, unit: durationUnit });
const value = Number(e.target.value);
setDurationValue(value);
onChange({ value, unit: 'h' });
};
const onDurationUnitChange = (e: ChangeEvent<HTMLSelectElement>) => {
const unit = e.target.value === 'm' ? 'm' : 'h';
setDurationUnit(unit);
onChange({ value: durationValue, unit });
};
useEffect(() => {
if (isValidDuration(durationValue, durationUnit)) {
setError(undefined);
} else {
setError(errorText);
}
}, [durationValue, durationUnit]);
const isValidDuration = (value: number, unit: DurationUnit): boolean => {
return (
(unit === 'm' && value >= MIN_DURATION_IN_MINUTES && value <= MAX_DURATION_IN_MINUTES) ||
(unit === 'h' && value <= MAX_DURATION_IN_HOURS)
);
};
const selectId = useGeneratedHtmlId({ prefix: 'durationUnitSelect' });
return (
<EuiFormRow label={rowLabel} fullWidth isInvalid={!!error} error={error}>
<EuiFormRow
label={getRowLabel(shortWindowDuration)}
fullWidth
isInvalid={hasError}
error={hasError ? errors[0] : undefined}
>
<EuiFlexGroup direction="row">
<EuiFlexItem grow={false} style={{ width: 100 }}>
<EuiFlexItem>
<EuiFieldNumber
isInvalid={!!error}
isInvalid={hasError}
min={1}
max={24}
step={1}
value={String(durationValue)}
onChange={onDurationValueChange}
aria-label={valueLabel}
data-test-subj="durationValueInput"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiSelect
id={selectId}
isInvalid={!!error}
options={durationUnitOptions}
value={durationUnit}
onChange={onDurationUnitChange}
aria-label={unitLabel}
data-test-subj="durationUnitSelect"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
);
}
const rowLabel = i18n.translate('xpack.observability.slo.rules.longWindow.rowLabel', {
defaultMessage: 'Long window',
});
const getRowLabel = (shortWindowDuration: Duration) => (
<>
{i18n.translate('xpack.observability.slo.rules.longWindow.rowLabel', {
defaultMessage: 'Lookback period (hours)',
})}{' '}
<EuiToolTip position="top" content={getTooltipText(shortWindowDuration)}>
<EuiIcon tabIndex={0} type="iInCircle" />
</EuiToolTip>
</>
);
const getTooltipText = (shortWindowDuration: Duration) =>
i18n.translate('xpack.observability.slo.rules.longWindowDuration.tooltip', {
defaultMessage:
'Lookback period over which the burn rate is computed. A shorter lookback period of {shortWindowDuration} minutes (1/12 the lookback period) will be used for faster recovery',
values: { shortWindowDuration: toMinutes(shortWindowDuration) },
});
const valueLabel = i18n.translate('xpack.observability.slo.rules.longWindow.valueLabel', {
defaultMessage: 'Enter a duration value for the long window',
});
const unitLabel = i18n.translate('xpack.observability.slo.rules.longWindow.unitLabel', {
defaultMessage: 'Select a duration unit for the long window',
});
const errorText = i18n.translate('xpack.observability.slo.rules.longWindow.errorText', {
defaultMessage:
'The long window must be at least 30 minutes and cannot exceed 24 hours or 1440 minutes.',
defaultMessage: 'Enter the lookback period in hours',
});

View file

@ -8,12 +8,12 @@
import React from 'react';
import { ComponentStory } from '@storybook/react';
import { SLO } from '../../../../typings';
import { SLO } from '../../../typings';
import { SloSelector as Component } from './slo_selector';
export default {
component: Component,
title: 'app/SLO/Shared/SloSelector',
title: 'app/SLO/BurnRateRule',
};
const Template: ComponentStory<typeof Component> = () => (
@ -21,5 +21,5 @@ const Template: ComponentStory<typeof Component> = () => (
);
const defaultProps = {};
export const Default = Template.bind({});
Default.args = defaultProps;
export const SloSelector = Template.bind({});
SloSelector.args = defaultProps;

View file

@ -5,17 +5,18 @@
* 2.0.
*/
import React from 'react';
import { act, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../../../../utils/test_helper';
import { SloSelector } from './slo_selector';
import { useFetchSloList } from '../../../../hooks/slo/use_fetch_slo_list';
import { wait } from '@testing-library/user-event/dist/utils';
import { emptySloList } from '../../../../../common/data/slo';
import React from 'react';
import { emptySloList } from '../../../../common/data/slo';
import { useFetchSloList } from '../../../hooks/slo/use_fetch_slo_list';
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_list');
const useFetchSloListMock = useFetchSloList as jest.Mock;
describe('SLO Selector', () => {

View file

@ -5,23 +5,30 @@
* 2.0.
*/
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { debounce } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import { SLO } from '../../../../typings';
import { useFetchSloList } from '../../../../hooks/slo/use_fetch_slo_list';
import { useFetchSloList } from '../../../hooks/slo/use_fetch_slo_list';
import { SLO } from '../../../typings';
interface Props {
initialSlo?: SLO;
errors?: string[];
onSelected: (slo: SLO | undefined) => void;
}
function SloSelector({ onSelected }: Props) {
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 { loading, sloList } = useFetchSloList({ name: searchValue, refetch: false });
const hasError = errors !== undefined && errors.length > 0;
useEffect(() => {
setSelectedOptions(initialSlo ? [{ value: initialSlo.id, label: initialSlo.name }] : []);
}, [initialSlo]);
useEffect(() => {
const isLoadedWithData = !loading && sloList !== undefined;
@ -41,25 +48,37 @@ function SloSelector({ onSelected }: Props) {
const onSearchChange = useMemo(() => debounce((value: string) => setSearchValue(value), 300), []);
return (
<EuiComboBox
aria-label={i18n.translate('xpack.observability.slo.sloSelector.ariaLabel', {
defaultMessage: 'SLO Selector',
})}
placeholder={i18n.translate('xpack.observability.slo.sloSelector.placeholder', {
defaultMessage: 'Select a SLO',
})}
data-test-subj="sloSelector"
singleSelection={{ asPlainText: true }}
options={options}
selectedOptions={selectedOptions}
async
isLoading={loading}
onChange={onChange}
<EuiFormRow
label={rowLabel}
fullWidth
onSearchChange={onSearchChange}
/>
isInvalid={hasError}
error={hasError ? errors[0] : undefined}
>
<EuiComboBox
aria-label={i18n.translate('xpack.observability.slo.rules.sloSelector.ariaLabel', {
defaultMessage: 'SLO',
})}
placeholder={i18n.translate('xpack.observability.slo.rules.sloSelector.placeholder', {
defaultMessage: 'Select a SLO',
})}
data-test-subj="sloSelector"
singleSelection={{ asPlainText: true }}
options={options}
selectedOptions={selectedOptions}
async
isLoading={loading}
onChange={onChange}
fullWidth
onSearchChange={onSearchChange}
isInvalid={hasError}
/>
</EuiFormRow>
);
}
const rowLabel = i18n.translate('xpack.observability.slo.rules.sloSelector.rowLabel', {
defaultMessage: 'SLO',
});
export { SloSelector };
export type { Props as SloSelectorProps };

View file

@ -0,0 +1,89 @@
/*
* 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 { BurnRateRuleParams } from '../../../typings';
import { validateBurnRateRule } from './validation';
const VALID_PARAMS: BurnRateRuleParams = {
sloId: 'irrelevant',
shortWindow: { value: 5, unit: 'm' },
longWindow: { value: 1, unit: 'h' },
maxBurnRateThreshold: 720,
burnRateThreshold: 14.4,
};
describe('ValidateBurnRateRule', () => {
it('requires a selected slo', () => {
const { errors } = validateBurnRateRule({ ...VALID_PARAMS, sloId: undefined });
expect(errors.sloId).toHaveLength(1);
});
it('requires a burn rate threshold', () => {
const { errors } = validateBurnRateRule({ ...VALID_PARAMS, burnRateThreshold: undefined });
expect(errors.burnRateThreshold).toHaveLength(1);
});
it('requires a max burn rate threshold', () => {
const { errors } = validateBurnRateRule({ ...VALID_PARAMS, maxBurnRateThreshold: undefined });
expect(errors.burnRateThreshold).toHaveLength(1);
});
it('validates burnRateThreshold is between 1 and maxBurnRateThreshold', () => {
expect(
validateBurnRateRule({
...VALID_PARAMS,
burnRateThreshold: 10.1,
maxBurnRateThreshold: 10,
}).errors.burnRateThreshold
).toHaveLength(1);
expect(
validateBurnRateRule({
...VALID_PARAMS,
burnRateThreshold: 0.99,
}).errors.burnRateThreshold
).toHaveLength(1);
expect(
validateBurnRateRule({
...VALID_PARAMS,
burnRateThreshold: 10,
maxBurnRateThreshold: 10,
}).errors.burnRateThreshold
).toHaveLength(0);
});
it('validates longWindow is between 1 and 24hours', () => {
expect(
validateBurnRateRule({
...VALID_PARAMS,
longWindow: { value: 0, unit: 'h' },
}).errors.longWindow.length
).toBe(1);
expect(
validateBurnRateRule({
...VALID_PARAMS,
longWindow: { value: 25, unit: 'h' },
}).errors.longWindow.length
).toBe(1);
expect(
validateBurnRateRule({
...VALID_PARAMS,
longWindow: { value: 24, unit: 'h' },
}).errors.longWindow.length
).toBe(0);
expect(
validateBurnRateRule({
...VALID_PARAMS,
longWindow: { value: 1, unit: 'h' },
}).errors.longWindow.length
).toBe(0);
});
});

View file

@ -0,0 +1,84 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { ValidationResult } from '@kbn/triggers-actions-ui-plugin/public';
import { BurnRateRuleParams, Duration } from '../../../typings';
export type ValidationBurnRateRuleResult = ValidationResult & {
errors: { sloId: string[]; longWindow: string[]; burnRateThreshold: string[] };
};
const MIN_DURATION_IN_HOURS = 1;
const MAX_DURATION_IN_HOURS = 24;
type Optional<T> = { [P in keyof T]?: T[P] };
export function validateBurnRateRule(
ruleParams: Optional<BurnRateRuleParams>
): ValidationBurnRateRuleResult {
const validationResult: ValidationBurnRateRuleResult = {
errors: {
sloId: new Array<string>(),
longWindow: new Array<string>(),
burnRateThreshold: new Array<string>(),
},
};
const { sloId, burnRateThreshold, maxBurnRateThreshold, longWindow } = ruleParams;
if (!sloId) {
validationResult.errors.sloId.push(SLO_REQUIRED);
}
if (burnRateThreshold === undefined || maxBurnRateThreshold === undefined) {
validationResult.errors.burnRateThreshold.push(BURN_RATE_THRESHOLD_REQUIRED);
} else if (burnRateThreshold < 1 || burnRateThreshold > maxBurnRateThreshold) {
validationResult.errors.burnRateThreshold.push(
getInvalidThresholdValueError(maxBurnRateThreshold)
);
}
if (longWindow === undefined) {
validationResult.errors.longWindow.push(LONG_WINDOW_DURATION_REQUIRED);
} else if (!isValidLongWindowDuration(longWindow)) {
validationResult.errors.longWindow.push(LONG_WINDOW_DURATION_INVALID);
}
return validationResult;
}
const isValidLongWindowDuration = (duration: Duration): boolean => {
const { unit, value } = duration;
return unit === 'h' && value >= MIN_DURATION_IN_HOURS && value <= MAX_DURATION_IN_HOURS;
};
const SLO_REQUIRED = i18n.translate('xpack.observability.slo.rules.burnRate.errors.sloRequired', {
defaultMessage: 'SLO is required.',
});
const LONG_WINDOW_DURATION_REQUIRED = i18n.translate(
'xpack.observability.slo.rules.burnRate.errors.windowDurationRequired',
{ defaultMessage: 'The lookback period is required.' }
);
const LONG_WINDOW_DURATION_INVALID = i18n.translate(
'xpack.observability.slo.rules.longWindow.errorText',
{
defaultMessage: 'The lookback period must be between 1 and 24 hours.',
}
);
const BURN_RATE_THRESHOLD_REQUIRED = i18n.translate(
'xpack.observability.slo.rules.burnRate.errors.burnRateThresholdRequired',
{ defaultMessage: 'Burn rate threshold is required.' }
);
const getInvalidThresholdValueError = (maxBurnRate: number) =>
i18n.translate('xpack.observability.slo.rules.burnRate.errors.invalidThresholdValue', {
defaultMessage: 'Burn rate threshold must be between 1 and {maxBurnRate}.',
values: { maxBurnRate },
});

View file

@ -7,23 +7,23 @@
import { HttpSetup } from '@kbn/core-http-browser';
import { useCallback, useMemo } from 'react';
import { toSLO } from '../../../utils/slo/slo';
import { useDataFetcher } from '../../../hooks/use_data_fetcher';
import { SLO } from '../../../typings';
import { toSLO } from '../../utils/slo/slo';
import { useDataFetcher } from '../use_data_fetcher';
import { SLO } from '../../typings';
interface UseFetchSloDetailsResponse {
loading: boolean;
slo: SLO | undefined;
}
function useFetchSloDetails(sloId: string): UseFetchSloDetailsResponse {
function useFetchSloDetails(sloId?: string): UseFetchSloDetailsResponse {
const params = useMemo(() => ({ sloId }), [sloId]);
const shouldExecuteApiCall = useCallback(
(apiCallParams: { sloId: string }) => params.sloId === apiCallParams.sloId,
(apiCallParams: { sloId?: string }) => params.sloId === apiCallParams.sloId,
[params]
);
const { loading, data: slo } = useDataFetcher<{ sloId: string }, SLO | undefined>({
const { loading, data: slo } = useDataFetcher<{ sloId?: string }, SLO | undefined>({
paramsForApiCall: params,
initialDataState: undefined,
executeApiCall: fetchSlo,
@ -34,10 +34,14 @@ function useFetchSloDetails(sloId: string): UseFetchSloDetailsResponse {
}
const fetchSlo = async (
params: { sloId: string },
params: { sloId?: string },
abortController: AbortController,
http: HttpSetup
): Promise<SLO | undefined> => {
if (params.sloId === undefined) {
return undefined;
}
try {
const response = await http.get<Record<string, unknown>>(
`/api/observability/slos/${params.sloId}`,

View file

@ -14,7 +14,7 @@ import { useKibana } from '../../utils/kibana_react';
import { kibanaStartMock } from '../../utils/kibana_react.mock';
import { render } from '../../utils/test_helper';
import { SloDetailsPage } from '.';
import { useFetchSloDetails } from './hooks/use_fetch_slo_details';
import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { useParams } from 'react-router-dom';
import { anSLO } from '../../../common/data/slo';
@ -23,8 +23,8 @@ jest.mock('react-router-dom', () => ({
useParams: jest.fn(),
}));
jest.mock('./hooks/use_fetch_slo_details');
jest.mock('../../utils/kibana_react');
jest.mock('../../hooks/slo/use_fetch_slo_details');
jest.mock('../../hooks/use_breadcrumbs');
const useFetchSloDetailsMock = useFetchSloDetails as jest.Mock;

View file

@ -20,7 +20,7 @@ import PageNotFound from '../404';
import { isSloFeatureEnabled } from '../slos/helpers';
import { SLOS_BREADCRUMB_TEXT } from '../slos/translations';
import { SloDetailsPathParams } from './types';
import { useFetchSloDetails } from './hooks/use_fetch_slo_details';
import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { SLO } from '../../typings';
import { SloDetails } from './components/slo_details';
import { SLO_DETAILS_BREADCRUMB_TEXT } from './translations';

View file

@ -54,6 +54,7 @@ import { getExploratoryViewEmbeddable } from './components/shared/exploratory_vi
import { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/exploratory_view_url';
import { createUseRulesLink } from './hooks/create_use_rules_link';
import getAppDataView from './utils/observability_data_views/get_app_data_view';
import { registerObservabilityRuleTypes } from './rules/register_observability_rule_types';
export interface ConfigSchema {
unsafe: {
@ -235,6 +236,8 @@ export class Plugin
coreSetup.application.register(app);
registerObservabilityRuleTypes(config, this.observabilityRuleTypeRegistry);
if (pluginsSetup.home) {
pluginsSetup.home.featureCatalogue.registerSolution({
id: observabilityFeatureId,

View file

@ -0,0 +1,48 @@
/*
* 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 { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import { ALERT_REASON } from '@kbn/rule-data-utils';
import { ConfigSchema } from '../plugin';
import { ObservabilityRuleTypeRegistry } from './create_observability_rule_type_registry';
import { SLO_BURN_RATE_RULE_ID } from '../../common/constants';
import { validateBurnRateRule } from '../components/app/burn_rate_rule_editor/validation';
export const registerObservabilityRuleTypes = (
config: ConfigSchema,
observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry
) => {
if (config.unsafe.slo.enabled) {
observabilityRuleTypeRegistry.register({
id: SLO_BURN_RATE_RULE_ID,
description: i18n.translate('xpack.observability.slo.rules.burnRate.description', {
defaultMessage: 'Alert when your SLO burn rate is too high over a defined period of time.',
}),
format: ({ fields }) => {
return {
reason: fields[ALERT_REASON] ?? '-',
link: '/app/observability/slos',
};
},
iconClass: 'bell',
documentationUrl(docLinks) {
return '/unknown/docs';
},
ruleParamsExpression: lazy(() => import('../components/app/burn_rate_rule_editor')),
validate: validateBurnRateRule,
requiresAppContext: false,
defaultActionMessage: i18n.translate(
'xpack.observability.slo.rules.burnRate.defaultActionMessage',
{
defaultMessage: `\\{\\{rule.name\\}\\} is firing:
- Reason: \\{\\{context.reason\\}\\}`,
}
),
});
}
};

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { RuleTypeParams } from '@kbn/alerting-plugin/common';
type DurationUnit = 'm' | 'h' | 'd' | 'w' | 'M' | 'Y';
interface Duration {
@ -36,4 +38,12 @@ interface SLOList {
total: number;
}
export type { Duration, DurationUnit, SLO, SLOList };
interface BurnRateRuleParams extends RuleTypeParams {
sloId: string;
burnRateThreshold: number;
maxBurnRateThreshold: number;
longWindow: Duration;
shortWindow: Duration;
}
export type { BurnRateRuleParams, Duration, DurationUnit, SLO, SLOList };

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { assertNever } from '@kbn/std';
import { Duration, DurationUnit } from '../../typings';
export function toDuration(duration: string): Duration {
@ -13,3 +14,22 @@ export function toDuration(duration: string): Duration {
return { value: parseInt(durationValue, 10), unit: durationUnit as DurationUnit };
}
export function toMinutes(duration: Duration) {
switch (duration.unit) {
case 'm':
return duration.value;
case 'h':
return duration.value * 60;
case 'd':
return duration.value * 24 * 60;
case 'w':
return duration.value * 7 * 24 * 60;
case 'M':
return duration.value * 30 * 24 * 60;
case 'Y':
return duration.value * 365 * 24 * 60;
}
assertNever(duration.unit);
}

View file

@ -24,17 +24,16 @@ import {
ALERT_EVALUATION_VALUE,
ALERT_REASON,
} from '@kbn/rule-data-utils';
import { FIRED_ACTION, getRuleExecutor } from './executor';
import { aStoredSLO, createSLO } from '../../../services/slo/fixtures/slo';
import { SLO } from '../../../domain/models';
import {
AlertStates,
BurnRateAlertContext,
BurnRateAlertState,
BurnRateAllowedActionGroups,
BurnRateRuleParams,
FIRED_ACTION,
AlertStates,
getRuleExecutor,
} from './executor';
import { aStoredSLO, createSLO } from '../../../services/slo/fixtures/slo';
import { SLO } from '../../../domain/models';
} from './types';
const commonEsResponse = {
took: 100,
@ -106,7 +105,7 @@ describe('BurnRateRuleExecutor', () => {
const executor = getRuleExecutor();
await executor({
params: someRuleParams({ sloId: slo.id, threshold: BURN_RATE_THRESHOLD }),
params: someRuleParams({ sloId: slo.id, burnRateThreshold: BURN_RATE_THRESHOLD }),
startedAt: new Date(),
services: servicesMock,
executionId: 'irrelevant',
@ -130,7 +129,7 @@ describe('BurnRateRuleExecutor', () => {
const executor = getRuleExecutor();
await executor({
params: someRuleParams({ sloId: slo.id, threshold: BURN_RATE_THRESHOLD }),
params: someRuleParams({ sloId: slo.id, burnRateThreshold: BURN_RATE_THRESHOLD }),
startedAt: new Date(),
services: servicesMock,
executionId: 'irrelevant',
@ -183,7 +182,7 @@ describe('BurnRateRuleExecutor', () => {
const executor = getRuleExecutor();
await executor({
params: someRuleParams({ sloId: slo.id, threshold: BURN_RATE_THRESHOLD }),
params: someRuleParams({ sloId: slo.id, burnRateThreshold: BURN_RATE_THRESHOLD }),
startedAt: new Date(),
services: servicesMock,
executionId: 'irrelevant',
@ -208,7 +207,7 @@ describe('BurnRateRuleExecutor', () => {
expect.objectContaining({
longWindow: { burnRate: 2, duration: '1h' },
shortWindow: { burnRate: 2, duration: '5m' },
threshold: 2,
burnRateThreshold: 2,
reason:
'The burn rate for the past 1h is 2 and for the past 5m is 2. Alert when above 2 for both windows',
})
@ -230,7 +229,7 @@ describe('BurnRateRuleExecutor', () => {
const executor = getRuleExecutor();
await executor({
params: someRuleParams({ sloId: slo.id, threshold: BURN_RATE_THRESHOLD }),
params: someRuleParams({ sloId: slo.id, burnRateThreshold: BURN_RATE_THRESHOLD }),
startedAt: new Date(),
services: servicesMock,
executionId: 'irrelevant',
@ -246,7 +245,7 @@ describe('BurnRateRuleExecutor', () => {
expect.objectContaining({
longWindow: { burnRate: 2.01, duration: '1h' },
shortWindow: { burnRate: 1.99, duration: '5m' },
threshold: 2,
burnRateThreshold: 2,
})
);
});
@ -255,9 +254,10 @@ describe('BurnRateRuleExecutor', () => {
function someRuleParams(params: Partial<BurnRateRuleParams> = {}): BurnRateRuleParams {
return {
sloId: uuid(),
threshold: 2,
longWindow: { duration: 1, unit: 'h' },
shortWindow: { duration: 5, unit: 'm' },
burnRateThreshold: 2,
maxBurnRateThreshold: 720,
longWindow: { value: 1, unit: 'h' },
shortWindow: { value: 5, unit: 'm' },
...params,
};
}

View file

@ -6,12 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import { RuleTypeState } from '@kbn/alerting-plugin/server';
import {
ActionGroupIdsOf,
AlertInstanceContext as AlertContext,
AlertInstanceState as AlertState,
} from '@kbn/alerting-plugin/common';
import {
ALERT_EVALUATION_THRESHOLD,
ALERT_EVALUATION_VALUE,
@ -22,24 +17,14 @@ import { LifecycleRuleExecutor } from '@kbn/rule-registry-plugin/server';
import { Duration, toDurationUnit } from '../../../domain/models';
import { DefaultSLIClient, KibanaSavedObjectsSLORepository } from '../../../services/slo';
import { computeBurnRate } from '../../../domain/services';
export enum AlertStates {
OK,
ALERT,
NO_DATA,
ERROR,
}
export type BurnRateRuleParams = {
sloId: string;
threshold: number;
longWindow: { duration: number; unit: string };
shortWindow: { duration: number; unit: string };
} & Record<string, any>;
export type BurnRateRuleTypeState = RuleTypeState & {};
export type BurnRateAlertState = AlertState;
export type BurnRateAlertContext = AlertContext;
export type BurnRateAllowedActionGroups = ActionGroupIdsOf<typeof FIRED_ACTION>;
import {
AlertStates,
BurnRateAlertContext,
BurnRateAlertState,
BurnRateAllowedActionGroups,
BurnRateRuleParams,
BurnRateRuleTypeState,
} from './types';
const SHORT_WINDOW = 'SHORT_WINDOW';
const LONG_WINDOW = 'LONG_WINDOW';
@ -64,11 +49,11 @@ export const getRuleExecutor = (): LifecycleRuleExecutor<
const slo = await sloRepository.findById(params.sloId);
const longWindowDuration = new Duration(
params.longWindow.duration,
params.longWindow.value,
toDurationUnit(params.longWindow.unit)
);
const shortWindowDuration = new Duration(
params.shortWindow.duration,
params.shortWindow.value,
toDurationUnit(params.shortWindow.unit)
);
@ -81,7 +66,8 @@ export const getRuleExecutor = (): LifecycleRuleExecutor<
const shortWindowBurnRate = computeBurnRate(slo, sliData[SHORT_WINDOW]);
const shouldAlert =
longWindowBurnRate >= params.threshold && shortWindowBurnRate >= params.threshold;
longWindowBurnRate >= params.burnRateThreshold &&
shortWindowBurnRate >= params.burnRateThreshold;
if (shouldAlert) {
const reason = buildReason(
@ -96,7 +82,7 @@ export const getRuleExecutor = (): LifecycleRuleExecutor<
longWindow: { burnRate: longWindowBurnRate, duration: longWindowDuration.format() },
reason,
shortWindow: { burnRate: shortWindowBurnRate, duration: shortWindowDuration.format() },
threshold: params.threshold,
burnRateThreshold: params.burnRateThreshold,
timestamp: startedAt.toISOString(),
};
@ -104,7 +90,7 @@ export const getRuleExecutor = (): LifecycleRuleExecutor<
id: `alert-${slo.id}-${slo.revision}`,
fields: {
[ALERT_REASON]: reason,
[ALERT_EVALUATION_THRESHOLD]: params.threshold,
[ALERT_EVALUATION_THRESHOLD]: params.burnRateThreshold,
[ALERT_EVALUATION_VALUE]: Math.min(longWindowBurnRate, shortWindowBurnRate),
},
});
@ -119,7 +105,7 @@ export const getRuleExecutor = (): LifecycleRuleExecutor<
const context = {
longWindow: { burnRate: longWindowBurnRate, duration: longWindowDuration.format() },
shortWindow: { burnRate: shortWindowBurnRate, duration: shortWindowDuration.format() },
threshold: params.threshold,
burnRateThreshold: params.burnRateThreshold,
timestamp: startedAt.toISOString(),
};
@ -144,13 +130,13 @@ function buildReason(
) {
return i18n.translate('xpack.observability.slo.alerting.burnRate.reason', {
defaultMessage:
'The burn rate for the past {longWindowDuration} is {longWindowBurnRate} and for the past {shortWindowDuration} is {shortWindowBurnRate}. Alert when above {threshold} for both windows',
'The burn rate for the past {longWindowDuration} is {longWindowBurnRate} and for the past {shortWindowDuration} is {shortWindowBurnRate}. Alert when above {burnRateThreshold} for both windows',
values: {
longWindowDuration: longWindowDuration.format(),
longWindowBurnRate,
shortWindowDuration: shortWindowDuration.format(),
shortWindowBurnRate,
threshold: params.threshold,
burnRateThreshold: params.burnRateThreshold,
},
});
}

View file

@ -13,8 +13,8 @@ import { createLifecycleExecutor } from '@kbn/rule-registry-plugin/server';
import { SLO_BURN_RATE_RULE_ID } from '../../../../common/constants';
import { FIRED_ACTION, getRuleExecutor } from './executor';
const windowSchema = schema.object({
duration: schema.number(),
const durationSchema = schema.object({
value: schema.number(),
unit: schema.string(),
});
@ -24,14 +24,15 @@ export function sloBurnRateRuleType(createLifecycleRuleExecutor: CreateLifecycle
return {
id: SLO_BURN_RATE_RULE_ID,
name: i18n.translate('xpack.observability.slo.rules.burnRate.name', {
defaultMessage: 'SLO Burn Rate',
defaultMessage: 'SLO burn rate',
}),
validate: {
params: schema.object({
sloId: schema.string(),
threshold: schema.number(),
longWindow: windowSchema,
shortWindow: windowSchema,
burnRateThreshold: schema.number(),
maxBurnRateThreshold: schema.number(),
longWindow: durationSchema,
shortWindow: durationSchema,
}),
},
defaultActionGroupId: FIRED_ACTION.id,
@ -45,7 +46,7 @@ export function sloBurnRateRuleType(createLifecycleRuleExecutor: CreateLifecycle
context: [
{ name: 'reason', description: reasonActionVariableDescription },
{ name: 'timestamp', description: timestampActionVariableDescription },
{ name: 'threshold', description: thresholdActionVariableDescription },
{ name: 'burnRateThreshold', description: thresholdActionVariableDescription },
{ name: 'longWindow', description: windowActionVariableDescription },
{ name: 'shortWindow', description: windowActionVariableDescription },
],
@ -56,7 +57,7 @@ export function sloBurnRateRuleType(createLifecycleRuleExecutor: CreateLifecycle
const thresholdActionVariableDescription = i18n.translate(
'xpack.observability.slo.alerting.thresholdDescription',
{
defaultMessage: 'The threshold value of the burn rate.',
defaultMessage: 'The burn rate threshold value.',
}
);

View file

@ -0,0 +1,30 @@
/*
* 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 { RuleTypeState } from '@kbn/alerting-plugin/server';
import {
ActionGroupIdsOf,
AlertInstanceContext as AlertContext,
AlertInstanceState as AlertState,
} from '@kbn/alerting-plugin/common';
import { FIRED_ACTION } from './executor';
export enum AlertStates {
OK,
ALERT,
}
export type BurnRateRuleParams = {
sloId: string;
burnRateThreshold: number;
maxBurnRateThreshold: number;
longWindow: { value: number; unit: string };
shortWindow: { value: number; unit: string };
} & Record<string, any>;
export type BurnRateRuleTypeState = RuleTypeState; // no specific rule state
export type BurnRateAlertState = AlertState; // no specific alert state
export type BurnRateAlertContext = AlertContext; // no specific alert context
export type BurnRateAllowedActionGroups = ActionGroupIdsOf<typeof FIRED_ACTION>;