From 134b25c1a3f4c556af607b995a2a053f5a2efa5e Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Tue, 30 Jan 2024 07:59:18 -0700 Subject: [PATCH] [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 image --- .../kbn-slo-schema/src/models/duration.ts | 4 + .../kbn-slo-schema/src/rest_specs/slo.ts | 19 +- .../public/hooks/slo/use_get_preview_data.ts | 12 +- .../components/events_chart_panel.tsx | 207 +++++++++++++----- .../slo_details/components/slo_details.tsx | 8 +- .../components/common/data_preview_chart.tsx | 4 +- .../timeslice_metric_indicator.tsx | 1 + .../server/services/slo/get_preview_data.ts | 45 +++- 8 files changed, 222 insertions(+), 78 deletions(-) diff --git a/x-pack/packages/kbn-slo-schema/src/models/duration.ts b/x-pack/packages/kbn-slo-schema/src/models/duration.ts index 33ff6cbd25ac8e..d507dcee6514e4 100644 --- a/x-pack/packages/kbn-slo-schema/src/models/duration.ts +++ b/x-pack/packages/kbn-slo-schema/src/models/duration.ts @@ -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 => { diff --git a/x-pack/packages/kbn-slo-schema/src/rest_specs/slo.ts b/x-pack/packages/kbn-slo-schema/src/rest_specs/slo.ts index 4f72827957ef7e..c939d25cfac150 100644 --- a/x-pack/packages/kbn-slo-schema/src/rest_specs/slo.ts +++ b/x-pack/packages/kbn-slo-schema/src/rest_specs/slo.ts @@ -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; type TimeWindow = t.OutputOf; type IndicatorType = t.OutputOf; type Indicator = t.OutputOf; +type Objective = t.OutputOf; type APMTransactionErrorRateIndicator = t.OutputOf; type APMTransactionDurationIndicator = t.OutputOf; type MetricCustomIndicator = t.OutputOf; @@ -350,6 +356,7 @@ export type { GetSLOInstancesResponse, IndicatorType, Indicator, + Objective, MetricCustomIndicator, TimesliceMetricIndicator, TimesliceMetricBasicMetricWithField, diff --git a/x-pack/plugins/observability/public/hooks/slo/use_get_preview_data.ts b/x-pack/plugins/observability/public/hooks/slo/use_get_preview_data.ts index 3a3b5d91871eaa..1cf3cb1c0a0025 100644 --- a/x-pack/plugins/observability/public/hooks/slo/use_get_preview_data.ts +++ b/x-pack/plugins/observability/public/hooks/slo/use_get_preview_data.ts @@ -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( '/internal/observability/slos/_preview', { - body: JSON.stringify({ indicator, range }), + body: JSON.stringify({ + indicator: { ...indicator, params: { ...indicator.params, filter } }, + range, + ...((objective && { objective }) || {}), + }), signal, } ); diff --git a/x-pack/plugins/observability/public/pages/slo_details/components/events_chart_panel.tsx b/x-pack/plugins/observability/public/pages/slo_details/components/events_chart_panel.tsx index fdb911c80c206b..2cca59fad87afb 100644 --- a/x-pack/plugins/observability/public/pages/slo_details/components/events_chart_panel.tsx +++ b/x-pack/plugins/observability/public/pages/slo_details/components/events_chart_panel.tsx @@ -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' ? ( + +

+ {i18n.translate('xpack.observability.slo.sloDetails.eventsChartPanel.title', { + defaultMessage: 'Good vs bad events', + })} +

+
+ ) : ( + +

+ {i18n.translate('xpack.observability.slo.sloDetails.eventsChartPanel.timesliceTitle', { + defaultMessage: 'Timeslice metric', + })} +

+
+ ); + 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 ? ( + <> + {threshold}} + markerPosition="right" + /> + + + ) : null; + return ( - - -

- {i18n.translate('xpack.observability.slo.sloDetails.eventsChartPanel.title', { - defaultMessage: 'Good vs bad events', - })} -

-
-
+ {title} {i18n.translate('xpack.observability.slo.sloDetails.eventsChartPanel.duration', { @@ -84,7 +159,7 @@ export function EventsChartPanel({ slo, range }: Props) { + {annotation} numeral(d).format('0,0')} + tickFormat={(d) => numeral(d).format(yAxisNumberFormat)} + domain={domain} /> - ({ - key: new Date(datum.date).getTime(), - value: datum.events?.good, - })) ?? [] - } - /> + {slo.indicator.type !== 'sli.metric.timeslice' ? ( + <> + ({ + key: new Date(datum.date).getTime(), + value: datum.events?.good, + })) ?? [] + } + /> - ({ - key: new Date(datum.date).getTime(), - value: datum.events?.bad, - })) ?? [] - } - /> + ({ + key: new Date(datum.date).getTime(), + value: datum.events?.bad, + })) ?? [] + } + /> + + ) : ( + ({ + date: new Date(datum.date).getTime(), + value: datum.sliValue >= 0 ? datum.sliValue : null, + }))} + /> + )} )} diff --git a/x-pack/plugins/observability/public/pages/slo_details/components/slo_details.tsx b/x-pack/plugins/observability/public/pages/slo_details/components/slo_details.tsx index 122ecd0c07aace..9de1d11a6849f0 100644 --- a/x-pack/plugins/observability/public/pages/slo_details/components/slo_details.tsx +++ b/x-pack/plugins/observability/public/pages/slo_details/components/slo_details.tsx @@ -160,11 +160,9 @@ export function SloDetails({ slo, isAutoRefreshing }: Props) { slo={slo} /> - {slo.indicator.type !== 'sli.metric.timeslice' ? ( - - - - ) : null} + + +
diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx index 8fd1039c0c53cd..fc805118d6f3ad 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx @@ -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(); 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'); diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/timeslice_metric/timeslice_metric_indicator.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/timeslice_metric/timeslice_metric_indicator.tsx index 1c01219ffe1a24..acc6344f732ad4 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/components/timeslice_metric/timeslice_metric_indicator.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/timeslice_metric/timeslice_metric_indicator.tsx @@ -179,6 +179,7 @@ export function TimesliceMetricIndicatorTypeForm() { thresholdDirection={['GT', 'GTE'].includes(comparator) ? 'above' : 'below'} thresholdColor={euiTheme.colors.warning} thresholdMessage={`${COMPARATOR_MAPPING[comparator]} ${threshold}`} + ignoreMoreThan100 /> diff --git a/x-pack/plugins/observability/server/services/slo/get_preview_data.ts b/x-pack/plugins/observability/server/services/slo/get_preview_data.ts index c1cd4d0d6caf18..0aa7ffbf941196 100644 --- a/x-pack/plugins/observability/server/services/slo/get_preview_data.ts +++ b/x-pack/plugins/observability/server/services/slo/get_preview_data.ts @@ -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 { 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`,