mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
feat(slo): Burn rate alert (#147557)
This commit is contained in:
parent
886289d206
commit
60867aacfb
22 changed files with 542 additions and 296 deletions
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
@ -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 = {};
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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;
|
|
@ -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', () => {
|
|
@ -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 };
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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 },
|
||||
});
|
|
@ -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}`,
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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\\}\\}`,
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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 };
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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.',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -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>;
|
Loading…
Add table
Add a link
Reference in a new issue