[SLO] Enable timeslice metric visualization on SLO detail page (#175281)

## Summary

This PR adds support for the Timeslice Metric visualization on the SLO
Detail page.

Fixes #170135 

<img width="1756" alt="image"
src="56599b91-8827-4c6a-9df1-ccd80c5ab097">
This commit is contained in:
Chris Cowan 2024-01-30 07:59:18 -07:00 committed by GitHub
parent f66db98da9
commit 134b25c1a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 222 additions and 78 deletions

View file

@ -57,6 +57,10 @@ class Duration {
asSeconds(): number {
return moment.duration(this.value, toMomentUnitOfTime(this.unit)).asSeconds();
}
asMinutes(): number {
return moment.duration(this.value, toMomentUnitOfTime(this.unit)).asMinutes();
}
}
const toDurationUnit = (unit: string): DurationUnit => {

View file

@ -60,13 +60,18 @@ const createSLOResponseSchema = t.type({
});
const getPreviewDataParamsSchema = t.type({
body: t.type({
indicator: indicatorSchema,
range: t.type({
start: t.number,
end: t.number,
body: t.intersection([
t.type({
indicator: indicatorSchema,
range: t.type({
start: t.number,
end: t.number,
}),
}),
}),
t.partial({
objective: objectiveSchema,
}),
]),
});
const getPreviewDataResponseSchema = t.array(previewDataSchema);
@ -282,6 +287,7 @@ type BudgetingMethod = t.OutputOf<typeof budgetingMethodSchema>;
type TimeWindow = t.OutputOf<typeof timeWindowTypeSchema>;
type IndicatorType = t.OutputOf<typeof indicatorTypesSchema>;
type Indicator = t.OutputOf<typeof indicatorSchema>;
type Objective = t.OutputOf<typeof objectiveSchema>;
type APMTransactionErrorRateIndicator = t.OutputOf<typeof apmTransactionErrorRateIndicatorSchema>;
type APMTransactionDurationIndicator = t.OutputOf<typeof apmTransactionDurationIndicatorSchema>;
type MetricCustomIndicator = t.OutputOf<typeof metricCustomIndicatorSchema>;
@ -350,6 +356,7 @@ export type {
GetSLOInstancesResponse,
IndicatorType,
Indicator,
Objective,
MetricCustomIndicator,
TimesliceMetricIndicator,
TimesliceMetricBasicMetricWithField,

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { GetPreviewDataResponse, Indicator } from '@kbn/slo-schema';
import { GetPreviewDataResponse, Indicator, Objective } from '@kbn/slo-schema';
import { useQuery } from '@tanstack/react-query';
import { useKibana } from '../../utils/kibana_react';
import { sloKeys } from './query_key_factory';
@ -21,7 +21,9 @@ export interface UseGetPreviewData {
export function useGetPreviewData(
isValid: boolean,
indicator: Indicator,
range: { start: number; end: number }
range: { start: number; end: number },
objective?: Objective,
filter?: string
): UseGetPreviewData {
const { http } = useKibana().services;
@ -31,7 +33,11 @@ export function useGetPreviewData(
const response = await http.post<GetPreviewDataResponse>(
'/internal/observability/slos/_preview',
{
body: JSON.stringify({ indicator, range }),
body: JSON.stringify({
indicator: { ...indicator, params: { ...indicator.params, filter } },
range,
...((objective && { objective }) || {}),
}),
signal,
}
);

View file

@ -6,10 +6,14 @@
*/
import {
AnnotationDomainType,
AreaSeries,
Axis,
BarSeries,
Chart,
LineAnnotation,
Position,
RectAnnotation,
ScaleType,
Settings,
Tooltip,
@ -28,11 +32,13 @@ import {
import numeral from '@elastic/numeral';
import { useActiveCursor } from '@kbn/charts-plugin/public';
import { i18n } from '@kbn/i18n';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema';
import moment from 'moment';
import React, { useRef } from 'react';
import { max, min } from 'lodash';
import { useGetPreviewData } from '../../../hooks/slo/use_get_preview_data';
import { useKibana } from '../../../utils/kibana_react';
import { COMPARATOR_MAPPING } from '../../slo_edit/constants';
export interface Props {
slo: SLOWithSummaryResponse;
@ -45,7 +51,8 @@ export interface Props {
export function EventsChartPanel({ slo, range }: Props) {
const { charts, uiSettings } = useKibana().services;
const { euiTheme } = useEuiTheme();
const { isLoading, data } = useGetPreviewData(true, slo.indicator, range);
const filter = slo.instanceId !== ALL_VALUE ? `${slo.groupBy}: "${slo.instanceId}"` : '';
const { isLoading, data } = useGetPreviewData(true, slo.indicator, range, slo.objective, filter);
const baseTheme = charts.theme.useChartsBaseTheme();
const chartRef = useRef(null);
const handleCursorUpdate = useActiveCursor(charts.activeCursor, chartRef, {
@ -54,19 +61,87 @@ export function EventsChartPanel({ slo, range }: Props) {
const dateFormat = uiSettings.get('dateFormat');
const title =
slo.indicator.type !== 'sli.metric.timeslice' ? (
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.observability.slo.sloDetails.eventsChartPanel.title', {
defaultMessage: 'Good vs bad events',
})}
</h2>
</EuiTitle>
) : (
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.observability.slo.sloDetails.eventsChartPanel.timesliceTitle', {
defaultMessage: 'Timeslice metric',
})}
</h2>
</EuiTitle>
);
const threshold =
slo.indicator.type === 'sli.metric.timeslice'
? slo.indicator.params.metric.threshold
: undefined;
const yAxisNumberFormat = slo.indicator.type === 'sli.metric.timeslice' ? '0,0[.00]' : '0,0';
const values = (data || []).map((row) => {
if (slo.indicator.type === 'sli.metric.timeslice') {
return row.sliValue;
} else {
return row?.events?.total || 0;
}
});
const maxValue = max(values);
const minValue = min(values);
const domain = {
fit: true,
min:
threshold != null && minValue != null && threshold < minValue ? threshold : minValue || NaN,
max:
threshold != null && maxValue != null && threshold > maxValue ? threshold : maxValue || NaN,
};
const annotation =
slo.indicator.type === 'sli.metric.timeslice' && threshold ? (
<>
<LineAnnotation
id="thresholdAnnotation"
domainType={AnnotationDomainType.YDomain}
dataValues={[{ dataValue: threshold }]}
style={{
line: {
strokeWidth: 2,
stroke: euiTheme.colors.warning || '#000',
opacity: 1,
},
}}
marker={<span>{threshold}</span>}
markerPosition="right"
/>
<RectAnnotation
dataValues={[
{
coordinates: ['GT', 'GTE'].includes(slo.indicator.params.metric.comparator)
? {
y0: threshold,
y1: maxValue,
}
: { y0: minValue, y1: threshold },
details: `${COMPARATOR_MAPPING[slo.indicator.params.metric.comparator]} ${threshold}`,
},
]}
id="thresholdShade"
style={{ fill: euiTheme.colors.warning || '#000', opacity: 0.1 }}
/>
</>
) : null;
return (
<EuiPanel paddingSize="m" color="transparent" hasBorder data-test-subj="eventsChartPanel">
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.observability.slo.sloDetails.eventsChartPanel.title', {
defaultMessage: 'Good vs bad events',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>{title}</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued" size="s">
{i18n.translate('xpack.observability.slo.sloDetails.eventsChartPanel.duration', {
@ -84,7 +159,7 @@ export function EventsChartPanel({ slo, range }: Props) {
<Tooltip type={TooltipType.VerticalCursor} />
<Settings
baseTheme={baseTheme}
showLegend
showLegend={slo.indicator.type !== 'sli.metric.timeslice'}
showLegendExtra={false}
legendPosition={Position.Left}
noResults={
@ -98,6 +173,7 @@ export function EventsChartPanel({ slo, range }: Props) {
pointerUpdateTrigger={'x'}
locale={i18n.getLocale()}
/>
{annotation}
<Axis
id="bottom"
@ -108,54 +184,71 @@ export function EventsChartPanel({ slo, range }: Props) {
<Axis
id="left"
position={Position.Left}
tickFormat={(d) => numeral(d).format('0,0')}
tickFormat={(d) => numeral(d).format(yAxisNumberFormat)}
domain={domain}
/>
<BarSeries
id={i18n.translate(
'xpack.observability.slo.sloDetails.eventsChartPanel.goodEventsLabel',
{ defaultMessage: 'Good events' }
)}
color={euiTheme.colors.success}
barSeriesStyle={{
rect: { fill: euiTheme.colors.success },
displayValue: { fill: euiTheme.colors.success },
}}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="key"
yAccessors={['value']}
stackAccessors={[0]}
data={
data?.map((datum) => ({
key: new Date(datum.date).getTime(),
value: datum.events?.good,
})) ?? []
}
/>
{slo.indicator.type !== 'sli.metric.timeslice' ? (
<>
<BarSeries
id={i18n.translate(
'xpack.observability.slo.sloDetails.eventsChartPanel.goodEventsLabel',
{ defaultMessage: 'Good events' }
)}
color={euiTheme.colors.success}
barSeriesStyle={{
rect: { fill: euiTheme.colors.success },
displayValue: { fill: euiTheme.colors.success },
}}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="key"
yAccessors={['value']}
stackAccessors={[0]}
data={
data?.map((datum) => ({
key: new Date(datum.date).getTime(),
value: datum.events?.good,
})) ?? []
}
/>
<BarSeries
id={i18n.translate(
'xpack.observability.slo.sloDetails.eventsChartPanel.badEventsLabel',
{ defaultMessage: 'Bad events' }
)}
color={euiTheme.colors.danger}
barSeriesStyle={{
rect: { fill: euiTheme.colors.danger },
displayValue: { fill: euiTheme.colors.danger },
}}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="key"
yAccessors={['value']}
stackAccessors={[0]}
data={
data?.map((datum) => ({
key: new Date(datum.date).getTime(),
value: datum.events?.bad,
})) ?? []
}
/>
<BarSeries
id={i18n.translate(
'xpack.observability.slo.sloDetails.eventsChartPanel.badEventsLabel',
{ defaultMessage: 'Bad events' }
)}
color={euiTheme.colors.danger}
barSeriesStyle={{
rect: { fill: euiTheme.colors.danger },
displayValue: { fill: euiTheme.colors.danger },
}}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="key"
yAccessors={['value']}
stackAccessors={[0]}
data={
data?.map((datum) => ({
key: new Date(datum.date).getTime(),
value: datum.events?.bad,
})) ?? []
}
/>
</>
) : (
<AreaSeries
id="Metric"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="date"
yAccessors={['value']}
data={(data ?? []).map((datum) => ({
date: new Date(datum.date).getTime(),
value: datum.sliValue >= 0 ? datum.sliValue : null,
}))}
/>
)}
</Chart>
)}
</EuiFlexItem>

View file

@ -160,11 +160,9 @@ export function SloDetails({ slo, isAutoRefreshing }: Props) {
slo={slo}
/>
</EuiFlexItem>
{slo.indicator.type !== 'sli.metric.timeslice' ? (
<EuiFlexItem>
<EventsChartPanel slo={slo} range={range} />
</EuiFlexItem>
) : null}
<EuiFlexItem>
<EventsChartPanel slo={slo} range={range} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
</Fragment>

View file

@ -48,6 +48,7 @@ interface DataPreviewChartProps {
thresholdDirection?: 'above' | 'below';
thresholdColor?: string;
thresholdMessage?: string;
ignoreMoreThan100?: boolean;
}
const ONE_HOUR_IN_MILLISECONDS = 1 * 60 * 60 * 1000;
@ -58,6 +59,7 @@ export function DataPreviewChart({
thresholdDirection,
thresholdColor,
thresholdMessage,
ignoreMoreThan100,
}: DataPreviewChartProps) {
const { watch, getFieldState, formState, getValues } = useFormContext<CreateSLOForm>();
const { charts, uiSettings } = useKibana().services;
@ -80,7 +82,7 @@ export function DataPreviewChart({
isError,
} = useDebouncedGetPreviewData(isIndicatorSectionValid, watch('indicator'), range);
const isMoreThan100 = previewData?.find((row) => row.sliValue > 1) != null;
const isMoreThan100 = !ignoreMoreThan100 && previewData?.find((row) => row.sliValue > 1) != null;
const baseTheme = charts.theme.useChartsBaseTheme();
const dateFormat = uiSettings.get('dateFormat');

View file

@ -179,6 +179,7 @@ export function TimesliceMetricIndicatorTypeForm() {
thresholdDirection={['GT', 'GTE'].includes(comparator) ? 'above' : 'below'}
thresholdColor={euiTheme.colors.warning}
thresholdMessage={`${COMPARATOR_MAPPING[comparator]} ${threshold}`}
ignoreMoreThan100
/>
</EuiFlexGroup>
</>

View file

@ -86,6 +86,10 @@ export class GetPreviewData {
date_histogram: {
field: '@timestamp',
fixed_interval: options.interval,
extended_bounds: {
min: options.range.start,
max: options.range.end,
},
},
aggs: {
_good: {
@ -172,6 +176,10 @@ export class GetPreviewData {
date_histogram: {
field: '@timestamp',
fixed_interval: options.interval,
extended_bounds: {
min: options.range.start,
max: options.range.end,
},
},
aggs: {
good: {
@ -233,6 +241,10 @@ export class GetPreviewData {
date_histogram: {
field: timestampField,
fixed_interval: options.interval,
extended_bounds: {
min: options.range.start,
max: options.range.end,
},
},
aggs: {
...getHistogramIndicatorAggregations.execute({
@ -284,6 +296,10 @@ export class GetPreviewData {
date_histogram: {
field: timestampField,
fixed_interval: options.interval,
extended_bounds: {
min: options.range.start,
max: options.range.end,
},
},
aggs: {
...getCustomMetricIndicatorAggregation.execute({
@ -337,6 +353,10 @@ export class GetPreviewData {
date_histogram: {
field: timestampField,
fixed_interval: options.interval,
extended_bounds: {
min: options.range.start,
max: options.range.end,
},
},
aggs: {
...getCustomMetricIndicatorAggregation.execute('metric'),
@ -376,6 +396,10 @@ export class GetPreviewData {
date_histogram: {
field: timestampField,
fixed_interval: options.interval,
extended_bounds: {
min: options.range.start,
max: options.range.end,
},
},
aggs: {
good: { filter: goodQuery },
@ -402,12 +426,21 @@ export class GetPreviewData {
public async execute(params: GetPreviewDataParams): Promise<GetPreviewDataResponse> {
try {
const bucketSize = Math.max(
calculateAuto
.near(100, moment.duration(params.range.end - params.range.start, 'ms'))
?.asMinutes() ?? 0,
1
);
// If the time range is 24h or less, then we want to use a 1m bucket for the
// Timeslice metric so that the chart is as close to the evaluation as possible.
// Otherwise due to how the statistics work, the values might not look like
// they've breached the threshold.
const bucketSize =
params.indicator.type === 'sli.metric.timeslice' &&
params.range.end - params.range.start <= 86_400_000 &&
params.objective?.timesliceWindow
? params.objective.timesliceWindow.asMinutes()
: Math.max(
calculateAuto
.near(100, moment.duration(params.range.end - params.range.start, 'ms'))
?.asMinutes() ?? 0,
1
);
const options: Options = {
range: params.range,
interval: `${bucketSize}m`,