mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
f66db98da9
commit
134b25c1a3
8 changed files with 222 additions and 78 deletions
|
@ -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 => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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`,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue