[9.0] feat(slo): update preview data API to show groups (#211801) (#213168)

# Backport

This will backport the following commits from `main` to `9.0`:
- [feat(slo): update preview data API to show groups
(#211801)](https://github.com/elastic/kibana/pull/211801)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Kevin
Delemme","email":"kevin.delemme@elastic.co"},"sourceCommit":{"committedDate":"2025-03-04T22:04:10Z","message":"feat(slo):
update preview data API to show groups
(#211801)","sha":"df59c2608302372273d573b6a9013944361ffeb4","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","backport:prev-minor","Team:obs-ux-management","v9.1.0"],"title":"feat(slo):
update preview data API to show
groups","number":211801,"url":"https://github.com/elastic/kibana/pull/211801","mergeCommit":{"message":"feat(slo):
update preview data API to show groups
(#211801)","sha":"df59c2608302372273d573b6a9013944361ffeb4"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/211801","number":211801,"mergeCommit":{"message":"feat(slo):
update preview data API to show groups
(#211801)","sha":"df59c2608302372273d573b6a9013944361ffeb4"}}]}]
BACKPORT-->
This commit is contained in:
Kevin Delemme 2025-03-05 09:10:40 -05:00 committed by GitHub
parent 4fbbea96bc
commit c897692644
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 4844 additions and 789 deletions

View file

@ -6,7 +6,7 @@
*/
import * as t from 'io-ts';
import { indicatorSchema, objectiveSchema } from '../../schema';
import { allOrAnyStringOrArray, dateType } from '../../schema/common';
import { dateType, groupingsSchema } from '../../schema/common';
const getPreviewDataParamsSchema = t.type({
body: t.intersection([
@ -19,32 +19,36 @@ const getPreviewDataParamsSchema = t.type({
}),
t.partial({
objective: objectiveSchema,
instanceId: t.string,
groupBy: allOrAnyStringOrArray,
remoteName: t.string,
groupings: t.record(t.string, t.unknown),
groupings: groupingsSchema,
groupBy: t.array(t.string),
}),
]),
});
const getPreviewDataResponseSchema = t.array(
t.intersection([
t.type({
date: dateType,
sliValue: t.union([t.number, t.null]),
const previewDataResponseSchema = t.intersection([
t.type({
date: dateType,
sliValue: t.union([t.number, t.null]),
}),
t.partial({
events: t.type({
good: t.number,
bad: t.number,
total: t.number,
}),
t.partial({
events: t.type({
good: t.number,
bad: t.number,
total: t.number,
}),
}),
])
);
}),
]);
const getPreviewDataResponseSchema = t.intersection([
t.type({
results: t.array(previewDataResponseSchema),
}),
t.partial({ groups: t.record(t.string, t.array(previewDataResponseSchema)) }),
]);
type GetPreviewDataParams = t.TypeOf<typeof getPreviewDataParamsSchema.props.body>;
type GetPreviewDataResponse = t.OutputOf<typeof getPreviewDataResponseSchema>;
export { getPreviewDataParamsSchema, getPreviewDataResponseSchema };
export { getPreviewDataParamsSchema, getPreviewDataResponseSchema, previewDataResponseSchema };
export type { GetPreviewDataParams, GetPreviewDataResponse };

View file

@ -40161,7 +40161,6 @@
"xpack.slo.burnRateRuleEditor.h5.defineMultipleBurnRateLabel": "Définir des fenêtres du taux d'avancement multiples",
"xpack.slo.burnRates.value": "{value} fois",
"xpack.slo.create.errorNotification": "Un problème est survenu lors de la création de {name}",
"xpack.slo.dataPreviewChart.noResultsLabel": "aucun résultat",
"xpack.slo.deleteConfirmationModal.cancelButtonLabel": "Annuler",
"xpack.slo.deleteConfirmationModal.deleteAllButtonLabel": "Supprimer le SLO et toutes les instances",
"xpack.slo.deleteConfirmationModal.deleteButtonLabel": "Supprimer",
@ -40493,14 +40492,10 @@
"xpack.slo.sloEdit.customKql.dataViewSelection.label": "Sélectionner une vue de données",
"xpack.slo.sloEdit.customKql.indexSelection.label": "Index",
"xpack.slo.sloEdit.customMetric.aggregationLabel": "Agrégation",
"xpack.slo.sloEdit.dataPreviewChart.badEvents": "Total des événements",
"xpack.slo.sloEdit.dataPreviewChart.errorMessage": "Les paramètres d'indicateur actuels ne sont pas valides",
"xpack.slo.sloEdit.dataPreviewChart.explanationMessage": "Remplir les champs d'indicateur pour visualiser les indicateurs actuels",
"xpack.slo.sloEdit.dataPreviewChart.goodEvents": "Bons événements",
"xpack.slo.sloEdit.dataPreviewChart.moreThan100": "Certaines des valeurs SLI sont supérieures à 100 %. Cela signifie qu'une bonne requête renvoie plus de résultats que la requête totale.",
"xpack.slo.sloEdit.dataPreviewChart.panelLabel": "Aperçu du SLI",
"xpack.slo.sloEdit.dataPreviewChart.syntheticsAvailability.xTitle": "Dernières 24 heures",
"xpack.slo.sloEdit.dataPreviewChart.xTitle": "Dernière heure",
"xpack.slo.sloEdit.dataPreviewChart.yTitle": "SLI",
"xpack.slo.sloEdit.definition.sliType": "Choisir le type de SLI",
"xpack.slo.sloEdit.definition.title": "Définir un SLI",

View file

@ -40020,7 +40020,6 @@
"xpack.slo.burnRateRuleEditor.h5.defineMultipleBurnRateLabel": "複数のバーンレート時間枠を定義",
"xpack.slo.burnRates.value": "{value}x",
"xpack.slo.create.errorNotification": "{name}の作成中に問題が発生しました",
"xpack.slo.dataPreviewChart.noResultsLabel": "結果なし",
"xpack.slo.deleteConfirmationModal.cancelButtonLabel": "キャンセル",
"xpack.slo.deleteConfirmationModal.deleteAllButtonLabel": "SLOとすべてのインスタンスを削除",
"xpack.slo.deleteConfirmationModal.deleteButtonLabel": "削除",
@ -40351,14 +40350,10 @@
"xpack.slo.sloEdit.customKql.dataViewSelection.label": "データビューを選択",
"xpack.slo.sloEdit.customKql.indexSelection.label": "インデックス",
"xpack.slo.sloEdit.customMetric.aggregationLabel": "アグリゲーション",
"xpack.slo.sloEdit.dataPreviewChart.badEvents": "全てのイベント",
"xpack.slo.sloEdit.dataPreviewChart.errorMessage": "現在のインジケーター設定は無効です",
"xpack.slo.sloEdit.dataPreviewChart.explanationMessage": "インジケーターフィールドに入力すると、現在のメトリックが可視化されます。",
"xpack.slo.sloEdit.dataPreviewChart.goodEvents": "良好なイベント数",
"xpack.slo.sloEdit.dataPreviewChart.moreThan100": "SLI値の一部が100%を超えています。つまり、質の高いクエリーは合計クエリーよりも多くの結果を返しています。",
"xpack.slo.sloEdit.dataPreviewChart.panelLabel": "SLIプレビュー",
"xpack.slo.sloEdit.dataPreviewChart.syntheticsAvailability.xTitle": "過去 24 時間",
"xpack.slo.sloEdit.dataPreviewChart.xTitle": "過去 1 時間",
"xpack.slo.sloEdit.dataPreviewChart.yTitle": "SLI",
"xpack.slo.sloEdit.definition.sliType": "SLIタイプを選択",
"xpack.slo.sloEdit.definition.title": "SLIを定義",

View file

@ -39440,7 +39440,6 @@
"xpack.slo.burnRateRuleEditor.h5.defineMultipleBurnRateLabel": "定义多个消耗速度窗口",
"xpack.slo.burnRates.value": "{value} 倍",
"xpack.slo.create.errorNotification": "创建 {name} 时出现问题",
"xpack.slo.dataPreviewChart.noResultsLabel": "无结果",
"xpack.slo.deleteConfirmationModal.cancelButtonLabel": "取消",
"xpack.slo.deleteConfirmationModal.deleteAllButtonLabel": "删除 SLO 和所有实例",
"xpack.slo.deleteConfirmationModal.deleteButtonLabel": "删除",
@ -39769,14 +39768,10 @@
"xpack.slo.sloEdit.customKql.dataViewSelection.label": "选择数据视图",
"xpack.slo.sloEdit.customKql.indexSelection.label": "索引",
"xpack.slo.sloEdit.customMetric.aggregationLabel": "聚合",
"xpack.slo.sloEdit.dataPreviewChart.badEvents": "事件合计",
"xpack.slo.sloEdit.dataPreviewChart.errorMessage": "当前指标设置无效",
"xpack.slo.sloEdit.dataPreviewChart.explanationMessage": "填写指标字段以查看当前指标的可视化",
"xpack.slo.sloEdit.dataPreviewChart.goodEvents": "良好事件",
"xpack.slo.sloEdit.dataPreviewChart.moreThan100": "某些 SLI 值大于 100%。这表明良好查询返回的结果比总体查询更多。",
"xpack.slo.sloEdit.dataPreviewChart.panelLabel": "SLI 预览",
"xpack.slo.sloEdit.dataPreviewChart.syntheticsAvailability.xTitle": "过去 24 小时",
"xpack.slo.sloEdit.dataPreviewChart.xTitle": "过去一小时",
"xpack.slo.sloEdit.dataPreviewChart.yTitle": "SLI",
"xpack.slo.sloEdit.definition.sliType": "选择 SLI 类型",
"xpack.slo.sloEdit.definition.title": "定义 SLI",

View file

@ -1,192 +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 {
Axis,
BarSeries,
Chart,
ElementClickListener,
Position,
ScaleType,
Settings,
XYChartElementEvent,
} from '@elastic/charts';
import { EuiIcon, EuiLoadingChart, useEuiTheme } from '@elastic/eui';
import numeral from '@elastic/numeral';
import { useActiveCursor } from '@kbn/charts-plugin/public';
import { i18n } from '@kbn/i18n';
import { GetPreviewDataResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import moment from 'moment';
import React, { useRef } from 'react';
import { useAnnotations } from '@kbn/observability-plugin/public';
import { TimeBounds } from '../../pages/slo_details/types';
import { getBrushTimeBounds } from '../../utils/slo/duration';
import { useKibana } from '../../hooks/use_kibana';
import { openInDiscover } from '../../utils/slo/get_discover_link';
export interface Props {
data: GetPreviewDataResponse;
slo?: SLOWithSummaryResponse;
annotation?: React.ReactNode;
isLoading?: boolean;
bottomTitle?: string;
onBrushed?: (timeBounds: TimeBounds) => void;
}
export function GoodBadEventsChart({
annotation,
bottomTitle,
data,
slo,
onBrushed,
isLoading = false,
}: Props) {
const { charts, uiSettings, discover } = useKibana().services;
const { euiTheme } = useEuiTheme();
const baseTheme = charts.theme.useChartsBaseTheme();
const chartRef = useRef(null);
const handleCursorUpdate = useActiveCursor(charts.activeCursor, chartRef, {
isDateHistogram: true,
});
const { ObservabilityAnnotations, annotations, wrapOnBrushEnd, onAnnotationClick } =
useAnnotations({
slo,
});
const dateFormat = uiSettings.get('dateFormat');
const yAxisNumberFormat = '0,0';
const domain = {
fit: true,
min: NaN,
max: NaN,
};
const intervalInMilliseconds =
data && data.length > 2
? moment(data[1].date).valueOf() - moment(data[0].date).valueOf()
: 10 * 60000;
const goodEventId = i18n.translate('xpack.slo.sloDetails.eventsChartPanel.goodEventsLabel', {
defaultMessage: 'Good events',
});
const badEventId = i18n.translate('xpack.slo.sloDetails.eventsChartPanel.badEventsLabel', {
defaultMessage: 'Bad events',
});
const barClickHandler = (params: XYChartElementEvent[]) => {
if (slo?.indicator?.type === 'sli.kql.custom') {
const [datum, eventDetail] = params[0];
const isBad = eventDetail.specId === badEventId;
const timeRange = {
from: moment(datum.x).toISOString(),
to: moment(datum.x).add(intervalInMilliseconds, 'ms').toISOString(),
mode: 'absolute' as const,
};
openInDiscover({ slo, showBad: isBad, showGood: !isBad, timeRange, discover, uiSettings });
}
};
return (
<>
{isLoading && <EuiLoadingChart size="m" mono data-test-subj="sliEventsChartLoading" />}
{!isLoading && (
<Chart size={{ height: 200, width: '100%' }} ref={chartRef}>
<ObservabilityAnnotations annotations={annotations} />
<Settings
theme={{
chartMargins: { top: 30 },
}}
baseTheme={baseTheme}
showLegend={true}
legendPosition={Position.Left}
noResults={
<EuiIcon
type="visualizeApp"
size="l"
color="subdued"
title={i18n.translate('xpack.slo.goodBadEventsChart.euiIcon.noResultsLabel', {
defaultMessage: 'no results',
})}
/>
}
onPointerUpdate={handleCursorUpdate}
externalPointerEvents={{
tooltip: { visible: true },
}}
pointerUpdateDebounce={0}
pointerUpdateTrigger={'x'}
locale={i18n.getLocale()}
onElementClick={barClickHandler as ElementClickListener}
onBrushEnd={wrapOnBrushEnd((brushArea) => {
onBrushed?.(getBrushTimeBounds(brushArea));
})}
onAnnotationClick={onAnnotationClick}
/>
{annotation}
<Axis
id="bottom"
title={bottomTitle}
position={Position.Bottom}
showOverlappingTicks
tickFormat={(d) => moment(d).format(dateFormat)}
/>
<Axis
id="left"
position={Position.Left}
tickFormat={(d) => numeral(d).format(yAxisNumberFormat)}
domain={domain}
/>
<>
<BarSeries
id={goodEventId}
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={badEventId}
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,
})) ?? []
}
/>
</>
</Chart>
)}
</>
);
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { Indicator } from '@kbn/slo-schema';
import type { Indicator, Objective } from '@kbn/slo-schema';
interface SloListFilter {
kqlQuery: string;
@ -61,11 +61,17 @@ export const sloKeys = {
instanceId: string | undefined,
windows: Array<{ name: string; duration: string }>
) => [...sloKeys.all, 'burnRates', sloId, instanceId, windows] as const,
preview: (
indicator: Indicator,
range: { from: Date; to: Date },
groupings?: Record<string, unknown>
) => [...sloKeys.all, 'preview', indicator, range, groupings] as const,
preview: (params: {
remoteName?: string;
groupings?: Record<string, string | number>;
objective?: Objective;
indicator: Indicator;
range: {
from: Date;
to: Date;
};
groupBy?: string[];
}) => [...sloKeys.all, 'preview', params] as const,
burnRateRules: (search: string) => [...sloKeys.all, 'burnRateRules', search],
groupings: (params: {
sloId: string;

View file

@ -20,16 +20,15 @@ export interface UseGetPreviewData {
interface Props {
isValid: boolean;
groupBy?: string | string[];
instanceId?: string;
remoteName?: string;
groupings?: Record<string, unknown>;
groupings?: Record<string, string | number>;
objective?: Objective;
indicator: Indicator;
range: {
from: Date;
to: Date;
};
groupBy?: string[];
}
export function useGetPreviewData({
@ -37,15 +36,21 @@ export function useGetPreviewData({
range,
indicator,
objective,
groupBy,
groupings,
instanceId,
remoteName,
groupBy,
}: Props): UseGetPreviewData {
const { sloClient } = usePluginContext();
const { isInitialLoading, isLoading, isError, isSuccess, data } = useQuery({
queryKey: sloKeys.preview(indicator, range, groupings),
queryKey: sloKeys.preview({
range,
indicator,
objective,
groupings,
remoteName,
groupBy,
}),
queryFn: async ({ signal }) => {
const response = await sloClient.fetch('POST /internal/observability/slos/_preview', {
params: {
@ -55,17 +60,16 @@ export function useGetPreviewData({
from: range.from.toISOString(),
to: range.to.toISOString(),
},
groupBy,
instanceId,
groupings,
remoteName,
...(objective ? { objective } : null),
objective,
groupBy,
},
},
signal,
});
return Array.isArray(response) ? response : [];
return response;
},
retry: false,
refetchOnWindowFocus: false,

View file

@ -1,160 +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 {
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLink,
EuiLoadingChart,
EuiPanel,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { max, min } from 'lodash';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { TimesliceAnnotation } from './timeslice_annotation';
import { EventsAreaChart } from './events_area_chart';
import { TimeBounds } from '../types';
import { SloTabId } from './slo_details';
import { useGetPreviewData } from '../../../hooks/use_get_preview_data';
import { useKibana } from '../../../hooks/use_kibana';
import { GoodBadEventsChart } from '../../../components/good_bad_events_chart/good_bad_events_chart';
import { getDiscoverLink } from '../../../utils/slo/get_discover_link';
export interface Props {
slo: SLOWithSummaryResponse;
range: { from: Date; to: Date };
selectedTabId: SloTabId;
onBrushed?: (timeBounds: TimeBounds) => void;
}
export function EventsChartPanel({ slo, range, selectedTabId, onBrushed }: Props) {
const { discover, uiSettings } = useKibana().services;
const { isLoading, data } = useGetPreviewData({
range,
isValid: true,
groupBy: slo.groupBy,
indicator: slo.indicator,
groupings: slo.groupings,
instanceId: slo.instanceId,
remoteName: slo.remote?.remoteName,
});
const title =
slo.indicator.type !== 'sli.metric.timeslice' ? (
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.slo.sloDetails.eventsChartPanel.title', {
defaultMessage: 'Good vs bad events',
})}
</h2>
</EuiTitle>
) : (
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.slo.sloDetails.eventsChartPanel.timesliceTitle', {
defaultMessage: 'Timeslice metric',
})}
</h2>
</EuiTitle>
);
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 annotation = <TimesliceAnnotation slo={slo} minValue={minValue} maxValue={maxValue} />;
const showViewEventsLink = ![
'sli.apm.transactionErrorRate',
'sli.apm.transactionDuration',
].includes(slo.indicator.type);
return (
<EuiPanel paddingSize="m" color="transparent" hasBorder data-test-subj="eventsChartPanel">
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem grow={1}> {title}</EuiFlexItem>
{selectedTabId !== 'history' && (
<EuiFlexItem>
<EuiText color="subdued" size="s">
{i18n.translate('xpack.slo.sloDetails.eventsChartPanel.duration', {
defaultMessage: 'Last 24h',
})}
</EuiText>
</EuiFlexItem>
)}
</EuiFlexGroup>
{showViewEventsLink && (
<EuiFlexItem grow={0}>
<EuiLink
color="text"
href={getDiscoverLink({
slo,
timeRange: {
from: 'now-24h',
to: 'now',
mode: 'relative',
},
discover,
uiSettings,
})}
data-test-subj="sloDetailDiscoverLink"
>
<EuiIcon type="sortRight" style={{ marginRight: '4px' }} />
<FormattedMessage
id="xpack.slo.sloDetails.viewEventsLink"
defaultMessage="View events"
/>
</EuiLink>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiFlexItem>
{slo.indicator.type !== 'sli.metric.timeslice' ? (
<GoodBadEventsChart
isLoading={isLoading}
data={data || []}
annotation={annotation}
slo={slo}
onBrushed={onBrushed}
/>
) : (
<>
{isLoading && (
<EuiLoadingChart size="m" mono data-test-subj="sliEventsChartLoading" />
)}
{!isLoading && (
<EventsAreaChart
slo={slo}
annotation={annotation}
minValue={minValue}
maxValue={maxValue}
onBrushed={onBrushed}
/>
)}
</>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}

View file

@ -0,0 +1,131 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLink,
EuiLoadingChart,
EuiPanel,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import React from 'react';
import { useGetPreviewData } from '../../../../hooks/use_get_preview_data';
import { useKibana } from '../../../../hooks/use_kibana';
import { TimeBounds } from '../../types';
import { getDiscoverLink } from '../../utils/get_discover_link';
import { GoodBadEventsChart } from './good_bad_events_chart';
import { MetricTimesliceEventsChart } from './metric_timeslice_events_chart';
export interface Props {
slo: SLOWithSummaryResponse;
range: { from: Date; to: Date };
hideRangeDurationLabel?: boolean;
onBrushed?: (timeBounds: TimeBounds) => void;
}
export function EventsChartPanel({ slo, range, hideRangeDurationLabel = false, onBrushed }: Props) {
const { discover, uiSettings } = useKibana().services;
const { isLoading, data } = useGetPreviewData({
range,
isValid: true,
indicator: slo.indicator,
groupings: slo.groupings,
objective: slo.objective,
remoteName: slo.remote?.remoteName,
});
const canLinkToDiscover = ![
'sli.apm.transactionErrorRate',
'sli.apm.transactionDuration',
].includes(slo.indicator.type);
function getChartTitle() {
switch (slo.indicator.type) {
case 'sli.metric.timeslice':
return i18n.translate('xpack.slo.sloDetails.eventsChartPanel.timesliceTitle', {
defaultMessage: 'Timeslice metric',
});
default:
return i18n.translate('xpack.slo.sloDetails.eventsChartPanel.title', {
defaultMessage: 'Good vs bad events',
});
}
}
function getChart() {
if (isLoading) {
return <EuiLoadingChart size="m" mono data-test-subj="eventsLoadingChart" />;
}
switch (slo.indicator.type) {
case 'sli.metric.timeslice':
return (
<MetricTimesliceEventsChart slo={slo} data={data?.results ?? []} onBrushed={onBrushed} />
);
default:
return <GoodBadEventsChart data={data?.results ?? []} slo={slo} onBrushed={onBrushed} />;
}
}
return (
<EuiPanel paddingSize="m" color="transparent" hasBorder data-test-subj="eventsChartPanel">
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem grow={1}>
<EuiTitle size="xs">
<h2>{getChartTitle()}</h2>
</EuiTitle>
</EuiFlexItem>
{!hideRangeDurationLabel && (
<EuiFlexItem>
<EuiText color="subdued" size="s">
{i18n.translate('xpack.slo.sloDetails.eventsChartPanel.duration', {
defaultMessage: 'Last 24h',
})}
</EuiText>
</EuiFlexItem>
)}
</EuiFlexGroup>
{canLinkToDiscover && (
<EuiFlexItem grow={0}>
<EuiLink
color="text"
href={getDiscoverLink({
slo,
timeRange: {
from: 'now-24h',
to: 'now',
mode: 'relative',
},
discover,
uiSettings,
})}
data-test-subj="sloDetailDiscoverLink"
>
<EuiIcon type="sortRight" style={{ marginRight: '4px' }} />
<FormattedMessage
id="xpack.slo.sloDetails.viewEventsLink"
defaultMessage="View events"
/>
</EuiLink>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiFlexItem>{getChart()}</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}

View file

@ -0,0 +1,161 @@
/*
* 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 {
Axis,
BarSeries,
Chart,
ElementClickListener,
Position,
ScaleType,
Settings,
XYChartElementEvent,
} from '@elastic/charts';
import { EuiIcon, useEuiTheme } from '@elastic/eui';
import numeral from '@elastic/numeral';
import { useActiveCursor } from '@kbn/charts-plugin/public';
import { i18n } from '@kbn/i18n';
import { useAnnotations } from '@kbn/observability-plugin/public';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import moment from 'moment';
import React, { useRef } from 'react';
import { useKibana } from '../../../../hooks/use_kibana';
import { getBrushTimeBounds } from '../../../../utils/slo/duration';
import { TimeBounds } from '../../types';
import { openInDiscover } from '../../utils/get_discover_link';
import { GetPreviewDataResponseResults } from './types';
export interface Props {
data: GetPreviewDataResponseResults;
slo: SLOWithSummaryResponse;
onBrushed?: (timeBounds: TimeBounds) => void;
}
export function GoodBadEventsChart({ data, slo, onBrushed }: Props) {
const { charts, uiSettings, discover } = useKibana().services;
const { euiTheme } = useEuiTheme();
const baseTheme = charts.theme.useChartsBaseTheme();
const chartRef = useRef(null);
const handleCursorUpdate = useActiveCursor(charts.activeCursor, chartRef, {
isDateHistogram: true,
});
const { ObservabilityAnnotations, annotations, wrapOnBrushEnd, onAnnotationClick } =
useAnnotations({ slo });
const dateFormat = uiSettings.get('dateFormat');
const intervalInMilliseconds =
data && data.length > 2
? moment(data[1].date).valueOf() - moment(data[0].date).valueOf()
: 10 * 60000;
const goodEventId = i18n.translate('xpack.slo.sloDetails.eventsChartPanel.goodEventsLabel', {
defaultMessage: 'Good events',
});
const badEventId = i18n.translate('xpack.slo.sloDetails.eventsChartPanel.badEventsLabel', {
defaultMessage: 'Bad events',
});
const barClickHandler = (params: XYChartElementEvent[]) => {
const [datum, eventDetail] = params[0];
const isBad = eventDetail.specId === badEventId;
const timeRange = {
from: moment(datum.x).toISOString(),
to: moment(datum.x).add(intervalInMilliseconds, 'ms').toISOString(),
mode: 'absolute' as const,
};
openInDiscover({ slo, showBad: isBad, showGood: !isBad, timeRange, discover, uiSettings });
};
return (
<Chart size={{ height: 200, width: '100%' }} ref={chartRef}>
<ObservabilityAnnotations annotations={annotations} />
<Settings
theme={{
chartMargins: { top: 30 },
}}
baseTheme={baseTheme}
showLegend={true}
legendPosition={Position.Left}
noResults={
<EuiIcon
type="visualizeApp"
size="l"
color="subdued"
title={i18n.translate('xpack.slo.goodBadEventsChart.euiIcon.noResultsLabel', {
defaultMessage: 'no results',
})}
/>
}
onPointerUpdate={handleCursorUpdate}
externalPointerEvents={{
tooltip: { visible: true },
}}
pointerUpdateDebounce={0}
pointerUpdateTrigger={'x'}
locale={i18n.getLocale()}
onElementClick={barClickHandler as ElementClickListener}
onBrushEnd={wrapOnBrushEnd((brushArea) => {
onBrushed?.(getBrushTimeBounds(brushArea));
})}
onAnnotationClick={onAnnotationClick}
/>
<Axis
id="bottom"
position={Position.Bottom}
showOverlappingTicks
tickFormat={(d) => moment(d).format(dateFormat)}
/>
<Axis
id="left"
position={Position.Left}
tickFormat={(d) => numeral(d).format('0')}
domain={{
fit: true,
min: NaN,
max: NaN,
}}
/>
<BarSeries
id={goodEventId}
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={badEventId}
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,
}))}
/>
</Chart>
);
}

View file

@ -5,28 +5,27 @@
* 2.0.
*/
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { AnnotationDomainType, LineAnnotation, RectAnnotation } from '@elastic/charts';
import React from 'react';
import { useEuiTheme } from '@elastic/eui';
import { COMPARATOR_MAPPING } from '../../slo_edit/constants';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import React from 'react';
import { COMPARATOR_MAPPING } from '../../../slo_edit/constants';
interface Props {
slo: SLOWithSummaryResponse;
maxValue?: number | null;
minValue?: number | null;
annotation?: React.ReactNode;
}
export function TimesliceAnnotation({ slo, maxValue, minValue }: Props) {
export function MetricTimesliceAnnotation({ slo, maxValue, minValue }: Props) {
const { euiTheme } = useEuiTheme();
const threshold =
slo.indicator.type === 'sli.metric.timeslice'
? slo.indicator.params.metric.threshold
: undefined;
if (slo.indicator.type !== 'sli.metric.timeslice') {
return null;
}
return slo.indicator.type === 'sli.metric.timeslice' && threshold ? (
const threshold = slo.indicator.params.metric.threshold;
return (
<>
<LineAnnotation
id="thresholdAnnotation"
@ -46,10 +45,7 @@ export function TimesliceAnnotation({ slo, maxValue, minValue }: Props) {
dataValues={[
{
coordinates: ['GT', 'GTE'].includes(slo.indicator.params.metric.comparator)
? {
y0: threshold,
y1: maxValue,
}
? { y0: threshold, y1: maxValue }
: { y0: minValue, y1: threshold },
details: `${COMPARATOR_MAPPING[slo.indicator.params.metric.comparator]} ${threshold}`,
},
@ -58,5 +54,5 @@ export function TimesliceAnnotation({ slo, maxValue, minValue }: Props) {
style={{ fill: euiTheme.colors.warning, opacity: 0.1 }}
/>
</>
) : null;
);
}

View file

@ -7,60 +7,53 @@
import { AreaSeries, Axis, Chart, Position, ScaleType, Settings } from '@elastic/charts';
import { EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import numeral from '@elastic/numeral';
import React, { useRef } from 'react';
import { useAnnotations } from '@kbn/observability-plugin/public';
import { GetPreviewDataResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useActiveCursor } from '@kbn/charts-plugin/public';
import { i18n } from '@kbn/i18n';
import { useAnnotations } from '@kbn/observability-plugin/public';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { max, min } from 'lodash';
import moment from 'moment';
import { getBrushTimeBounds } from '../../../utils/slo/duration';
import { TimeBounds } from '../types';
import { useKibana } from '../../../hooks/use_kibana';
import React, { useRef } from 'react';
import { useKibana } from '../../../../hooks/use_kibana';
import { getBrushTimeBounds } from '../../../../utils/slo/duration';
import { TimeBounds } from '../../types';
import { MetricTimesliceAnnotation } from './metric_timeslice_annotation';
import { GetPreviewDataResponseResults } from './types';
export function EventsAreaChart({
slo,
data,
minValue,
maxValue,
annotation,
onBrushed,
}: {
data?: GetPreviewDataResponse;
maxValue?: number | null;
minValue?: number | null;
interface Props {
data: GetPreviewDataResponseResults;
slo: SLOWithSummaryResponse;
annotation?: React.ReactNode;
onBrushed?: (timeBounds: TimeBounds) => void;
}) {
}
export function MetricTimesliceEventsChart({ slo, data, onBrushed }: Props) {
const { charts, uiSettings } = useKibana().services;
const chartRef = useRef(null);
const baseTheme = charts.theme.useChartsBaseTheme();
const dateFormat = uiSettings.get('dateFormat');
const chartRef = useRef(null);
const yAxisNumberFormat = slo.indicator.type === 'sli.metric.timeslice' ? '0,0[.00]' : '0,0';
const handleCursorUpdate = useActiveCursor(charts.activeCursor, chartRef, {
isDateHistogram: true,
});
const { ObservabilityAnnotations, annotations, wrapOnBrushEnd } = useAnnotations({
slo,
});
const threshold =
slo.indicator.type === 'sli.metric.timeslice'
? slo.indicator.params.metric.threshold
: undefined;
if (slo.indicator.type !== 'sli.metric.timeslice') {
return null;
}
const values = data.map((row) => row.sliValue);
const maxValue = max(values);
const minValue = min(values);
const threshold = slo.indicator.params.metric.threshold;
const domain = {
fit: true,
min:
threshold != null && minValue != null && threshold < minValue ? threshold : minValue || NaN,
max:
threshold != null && maxValue != null && threshold > maxValue ? threshold : maxValue || NaN,
min: min([threshold, minValue]) ?? NaN,
max: max([threshold, maxValue]) ?? NaN,
};
const { ObservabilityAnnotations, annotations, wrapOnBrushEnd } = useAnnotations({
domain,
slo,
});
return (
<Chart size={{ height: 150, width: '100%' }} ref={chartRef}>
<ObservabilityAnnotations annotations={annotations} />
@ -89,8 +82,7 @@ export function EventsAreaChart({
onBrushed?.(getBrushTimeBounds(brushArea));
})}
/>
{annotation}
<MetricTimesliceAnnotation slo={slo} minValue={minValue} maxValue={maxValue} />
<Axis
id="bottom"
position={Position.Bottom}
@ -100,7 +92,7 @@ export function EventsAreaChart({
<Axis
id="left"
position={Position.Left}
tickFormat={(d) => numeral(d).format(yAxisNumberFormat)}
tickFormat={(d) => numeral(d).format('0,0[.00]')}
domain={domain}
/>
<AreaSeries
@ -109,7 +101,7 @@ export function EventsAreaChart({
yScaleType={ScaleType.Linear}
xAccessor="date"
yAccessors={['value']}
data={(data ?? []).map((datum) => ({
data={data.map((datum) => ({
date: new Date(datum.date).getTime(),
value: datum.sliValue,
}))}

View file

@ -0,0 +1,10 @@
/*
* 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 { GetPreviewDataResponse } from '@kbn/slo-schema';
export type GetPreviewDataResponseResults = GetPreviewDataResponse['results'];

View file

@ -20,7 +20,7 @@ import React, { useMemo, useState } from 'react';
import { ErrorRateChart } from '../../../../components/slo/error_rate_chart';
import { useKibana } from '../../../../hooks/use_kibana';
import { TimeBounds } from '../../types';
import { EventsChartPanel } from '../events_chart_panel';
import { EventsChartPanel } from '../events_chart_panel/events_chart_panel';
import { HistoricalDataCharts } from '../historical_data_charts';
import { SloTabId } from '../slo_details';
@ -111,12 +111,7 @@ export function SLODetailsHistory({ slo, isAutoRefreshing, selectedTabId }: Prop
onBrushed={onBrushed}
/>
<EventsChartPanel
slo={slo}
range={range}
selectedTabId={selectedTabId}
onBrushed={onBrushed}
/>
<EventsChartPanel slo={slo} range={range} hideRangeDurationLabel onBrushed={onBrushed} />
</EuiFlexGroup>
);
}

View file

@ -10,7 +10,7 @@ import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import moment from 'moment';
import React, { useEffect, useState } from 'react';
import { BurnRatePanel } from './burn_rate_panel/burn_rate_panel';
import { EventsChartPanel } from './events_chart_panel';
import { EventsChartPanel } from './events_chart_panel/events_chart_panel';
import { HistoricalDataCharts } from './historical_data_charts';
import { SLODetailsHistory } from './history/slo_details_history';
import { Overview } from './overview/overview';
@ -76,7 +76,7 @@ export function SloDetails({ slo, isAutoRefreshing, selectedTabId }: Props) {
isAutoRefreshing={isAutoRefreshing}
/>
<EventsChartPanel slo={slo} range={range} selectedTabId={selectedTabId} />
<EventsChartPanel slo={slo} range={range} />
</EuiFlexGroup>
</EuiFlexGroup>
);

View file

@ -4,15 +4,15 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { IUiSettingsClient } from '@kbn/core/public';
import { getEsQueryConfig } from '@kbn/data-plugin/public';
import { DiscoverStart } from '@kbn/discover-plugin/public';
import { ALL_VALUE, kqlWithFiltersSchema, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { Filter, FilterStateStore, TimeRange } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { buildEsQuery } from '@kbn/observability-plugin/public';
import { v4 } from 'uuid';
import { kqlWithFiltersSchema, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { isEmpty } from 'lodash';
import { getEsQueryConfig } from '@kbn/data-plugin/public';
import { IUiSettingsClient } from '@kbn/core/public';
import { v4 } from 'uuid';
function createDiscoverLocator({
slo,
@ -106,49 +106,40 @@ function createDiscoverLocator({
});
}
const groupBy = [slo.groupBy].flat();
if (!isEmpty(slo.groupings)) {
const groupingKeys = Object.keys(slo.groupings);
const groupingFilters = groupingKeys.map((key) => ({
meta: {
disabled: false,
negate: false,
alias: null,
key,
params: {
query: slo.groupings[key],
},
type: 'phrase',
index: indexId,
},
$state: {
store: FilterStateStore.APP_STATE,
},
query: {
match_phrase: {
[key]: slo.groupings[key],
},
},
}));
if (
!isEmpty(slo.groupings) &&
groupBy.length > 0 &&
groupBy.every((field) => field === ALL_VALUE) === false
) {
groupBy.forEach((field) => {
filters.push({
meta: {
disabled: false,
negate: false,
alias: null,
key: field,
params: {
query: slo.groupings[field],
},
type: 'phrase',
index: indexId,
},
$state: {
store: FilterStateStore.APP_STATE,
},
query: {
match_phrase: {
[field]: slo.groupings[field],
},
},
});
});
filters.push(...groupingFilters);
}
const timeFieldName =
slo.indicator.type !== 'sli.apm.transactionDuration' &&
slo.indicator.type !== 'sli.apm.transactionErrorRate' &&
slo.indicator.type !== 'sli.synthetics.availability'
? slo.indicator.params.timestampField
: '@timestamp';
'timestampField' in slo.indicator.params ? slo.indicator.params.timestampField : '@timestamp';
return {
timeRange,
query: {
query: filterKuery || '',
query: filterKuery ?? '',
language: 'kuery',
},
filters,

View file

@ -7,10 +7,10 @@
import {
AnnotationDomainType,
AreaSeries,
Axis,
Chart,
LineAnnotation,
LineSeries,
Position,
RectAnnotation,
ScaleType,
@ -24,7 +24,6 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIcon,
EuiLoadingChart,
EuiPanel,
EuiSpacer,
@ -33,12 +32,12 @@ import {
import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { max, min } from 'lodash';
import { GetPreviewDataResponse } from '@kbn/slo-schema';
import { map, max, min, values } from 'lodash';
import moment from 'moment';
import React, { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useKibana } from '../../../../hooks/use_kibana';
import { GoodBadEventsChart } from '../../../../components/good_bad_events_chart/good_bad_events_chart';
import { useDebouncedGetPreviewData } from '../../hooks/use_preview';
import { useSectionFormValidation } from '../../hooks/use_section_form_validation';
import { CreateSLOForm } from '../../types';
@ -50,24 +49,16 @@ interface DataPreviewChartProps {
thresholdColor?: string;
thresholdMessage?: string;
ignoreMoreThan100?: boolean;
useGoodBadEventsChart?: boolean;
label?: string;
range?: {
from: Date;
to: Date;
};
}
export function DataPreviewChart({
formatPattern,
// Specific to timeslice metric indicator type
threshold,
thresholdDirection,
thresholdColor,
thresholdMessage,
ignoreMoreThan100,
label,
useGoodBadEventsChart,
range,
}: DataPreviewChartProps) {
const { watch, getFieldState, formState, getValues } = useFormContext<CreateSLOForm>();
const { charts, uiSettings } = useKibana().services;
@ -78,22 +69,24 @@ export function DataPreviewChart({
watch,
});
const [defaultRange, _] = useState({
from: moment().subtract(1, 'hour').toDate(),
const [range, _] = useState({
from: moment().subtract(1, 'day').toDate(),
to: new Date(),
});
const indicator = watch('indicator');
const groupBy = watch('groupBy');
const {
data: previewData,
isLoading: isPreviewLoading,
isLoading,
isSuccess,
isError,
} = useDebouncedGetPreviewData(isIndicatorSectionValid, indicator, range ?? defaultRange);
} = useDebouncedGetPreviewData(isIndicatorSectionValid, indicator, range, groupBy);
const isMoreThan100 =
!ignoreMoreThan100 && previewData?.find((row) => row.sliValue && row.sliValue > 1) != null;
!ignoreMoreThan100 &&
previewData?.results.some((datum) => datum.sliValue && datum.sliValue > 1);
const baseTheme = charts.theme.useChartsBaseTheme();
const dateFormat = uiSettings.get('dateFormat');
@ -102,17 +95,7 @@ export function DataPreviewChart({
? formatPattern
: (uiSettings.get('format:percent:defaultPattern') as string);
// map values to row.sliValue and filter out no data values
const values = (previewData || []).map((row) => row.sliValue);
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 { maxValue, minValue, domain } = getChartDomain(previewData, threshold);
const title = (
<>
@ -157,7 +140,7 @@ export function DataPreviewChart({
style={{
line: {
strokeWidth: 2,
stroke: thresholdColor || '#000',
stroke: thresholdColor ?? '#000',
opacity: 1,
},
}}
@ -167,16 +150,13 @@ export function DataPreviewChart({
{
coordinates:
thresholdDirection === 'above'
? {
y0: threshold,
y1: maxValue,
}
? { y0: threshold, y1: maxValue }
: { y0: minValue, y1: threshold },
details: thresholdMessage,
},
]}
id="thresholdShade"
style={{ fill: thresholdColor || '#000', opacity: 0.1 }}
style={{ fill: thresholdColor ?? '#000', opacity: 0.1 }}
/>
</>
);
@ -187,27 +167,53 @@ export function DataPreviewChart({
type: 'color',
},
{
id: 'label',
id: 'group',
type: 'custom',
header: i18n.translate('xpack.slo.sloEdit.dataPreviewChart.tooltip.groupLabel', {
defaultMessage: 'Group',
}),
truncate: true,
cell: ({ label: cellLabel }) => <span className="echTooltip__label">{cellLabel}</span>,
style: {
textAlign: 'left',
},
style: { textAlign: 'left' },
},
{
id: 'value',
id: 'sli',
type: 'custom',
header: i18n.translate('xpack.slo.sloEdit.dataPreviewChart.tooltip.sliLabel', {
defaultMessage: 'SLI',
}),
cell: ({ formattedValue }) => (
<>
<span className="echTooltip__value" dir="ltr">
{formattedValue}
</span>
</>
<span className="echTooltip__value" dir="ltr">
{formattedValue}
</span>
),
style: {
textAlign: 'right',
},
style: { textAlign: 'right' },
},
{
id: 'good',
type: 'custom',
header: i18n.translate('xpack.slo.sloEdit.dataPreviewChart.tooltip.goodEventsLabel', {
defaultMessage: 'Good events',
}),
cell: ({ datum }) => (
<span className="echTooltip__value" dir="ltr">
{datum.events?.good ?? '-'}
</span>
),
style: { textAlign: 'right' },
},
{
id: 'total',
type: 'custom',
header: i18n.translate('xpack.slo.sloEdit.dataPreviewChart.tooltip.totalEventsLabel', {
defaultMessage: 'Total events',
}),
cell: ({ datum }) => (
<span className="echTooltip__value" dir="ltr">
{datum.events?.total ?? '-'}
</span>
),
style: { textAlign: 'right' },
},
];
@ -231,10 +237,10 @@ export function DataPreviewChart({
)}
<EuiFormRow fullWidth>
<EuiPanel hasBorder={true} hasShadow={false} style={{ minHeight: 194 }}>
{(isPreviewLoading || isError) && (
{(isLoading || isError) && (
<EuiFlexGroup justifyContent="center" alignItems="center" style={{ height: 160 }}>
<EuiFlexItem grow={false}>
{isPreviewLoading && <EuiLoadingChart size="m" mono />}
{isLoading && <EuiLoadingChart size="m" mono />}
{isError && (
<span>
{i18n.translate('xpack.slo.sloEdit.dataPreviewChart.errorMessage', {
@ -245,71 +251,27 @@ export function DataPreviewChart({
</EuiFlexItem>
</EuiFlexGroup>
)}
{isSuccess && useGoodBadEventsChart && (
<GoodBadEventsChart
data={previewData || []}
bottomTitle={label || DEFAULT_LABEL}
isLoading={isPreviewLoading}
annotation={annotation}
/>
)}
{isSuccess && !useGoodBadEventsChart && (
{isSuccess && (
<Chart size={{ height: 160, width: '100%' }}>
<Tooltip
type="vertical"
body={({ items }) => {
const firstItem = items[0];
const events = firstItem.datum.events;
const rows = [items[0]];
if (events) {
rows.push({
...firstItem,
formattedValue: events.good,
value: events.good,
label: i18n.translate('xpack.slo.sloEdit.dataPreviewChart.goodEvents', {
defaultMessage: 'Good events',
}),
});
rows.push({
...firstItem,
value: events.total,
formattedValue: events.total,
label: i18n.translate('xpack.slo.sloEdit.dataPreviewChart.badEvents', {
defaultMessage: 'Total events',
}),
});
}
return <TooltipTable columns={columns} items={rows} />;
return <TooltipTable columns={columns} items={items} />;
}}
/>
<Settings
baseTheme={baseTheme}
showLegend={false}
theme={[
{
lineSeriesStyle: {
point: { visible: 'never' },
},
},
]}
noResults={
<EuiIcon
type="visualizeApp"
size="l"
color="subdued"
title={i18n.translate('xpack.slo.dataPreviewChart.noResultsLabel', {
defaultMessage: 'no results',
})}
/>
}
showLegend={true}
legendPosition={Position.Right}
theme={[{ lineSeriesStyle: { point: { visible: 'never' } } }]}
locale={i18n.getLocale()}
/>
{annotation}
<Axis
id="y-axis"
id="value"
title={i18n.translate('xpack.slo.sloEdit.dataPreviewChart.yTitle', {
defaultMessage: 'SLI',
})}
@ -318,10 +280,11 @@ export function DataPreviewChart({
tickFormat={(d) => numeral(d).format(numberFormat)}
domain={domain}
/>
<Axis
id="time"
title={label || DEFAULT_LABEL}
title={i18n.translate('xpack.slo.sloEdit.dataPreviewChart.xTitle', {
defaultMessage: 'Last 24 hours',
})}
tickFormat={(d) => moment(d).format(dateFormat)}
position={Position.Bottom}
timeAxisLayerCount={2}
@ -338,18 +301,35 @@ export function DataPreviewChart({
},
}}
/>
<AreaSeries
id="SLI"
<LineSeries
id="All groups"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="date"
yAccessors={['value']}
data={(previewData ?? []).map((datum) => ({
data={(previewData?.results ?? []).map((datum) => ({
date: new Date(datum.date).getTime(),
value: datum.sliValue && datum.sliValue >= 0 ? datum.sliValue : null,
events: datum.events,
}))}
/>
{map(previewData?.groups, (data, group) => (
<LineSeries
key={group}
id={group}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="date"
yAccessors={['value']}
data={data.map((datum) => ({
date: new Date(datum.date).getTime(),
value: datum.sliValue && datum.sliValue >= 0 ? datum.sliValue : null,
events: datum.events,
}))}
/>
))}
</Chart>
)}
</EuiPanel>
@ -358,6 +338,17 @@ export function DataPreviewChart({
);
}
const DEFAULT_LABEL = i18n.translate('xpack.slo.sloEdit.dataPreviewChart.xTitle', {
defaultMessage: 'Last hour',
});
function getChartDomain(previewData?: GetPreviewDataResponse, threshold?: number) {
const allGroupsValues = map(previewData?.results, (datum) => datum.sliValue);
const groupsValues = values(previewData?.groups)
.flat()
.map((datum) => datum.sliValue);
const maxValue = max(allGroupsValues.concat(groupsValues));
const minValue = min(allGroupsValues.concat(groupsValues));
const domain = {
fit: true,
min: min([threshold, minValue]) ?? NaN,
max: max([threshold, maxValue]) ?? NaN,
};
return { maxValue, minValue, domain };
}

View file

@ -13,16 +13,15 @@ import {
QuerySchema,
SyntheticsAvailabilityIndicator,
} from '@kbn/slo-schema';
import moment from 'moment';
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import { DATA_VIEW_FIELD } from '../custom_common/index_selection';
import { useCreateDataView } from '../../../../../hooks/use_create_data_view';
import { formatAllFilters } from '../../../helpers/format_filters';
import { CreateSLOForm } from '../../../types';
import { DataPreviewChart } from '../../common/data_preview_chart';
import { GroupByCardinality } from '../../common/group_by_cardinality';
import { QueryBuilder } from '../../common/query_builder';
import { DATA_VIEW_FIELD } from '../custom_common/index_selection';
import { FieldSelector } from '../synthetics_common/field_selector';
export function SyntheticsAvailabilityIndicatorTypeForm() {
@ -43,11 +42,6 @@ export function SyntheticsAvailabilityIndicatorTypeForm() {
dataViewId,
});
const [range, _] = useState({
from: moment().subtract(1, 'day').toDate(),
to: new Date(),
});
const filters = {
monitorIds: monitorIds.map((id) => id.value).filter((id) => id !== ALL_VALUE),
projects: projects.map((project) => project.value).filter((id) => id !== ALL_VALUE),
@ -164,15 +158,11 @@ export function SyntheticsAvailabilityIndicatorTypeForm() {
})}
customFilters={allFilters as QuerySchema}
/>
<DataPreviewChart range={range} label={LABEL} useGoodBadEventsChart />
<DataPreviewChart />
</EuiFlexGroup>
);
}
const LABEL = i18n.translate('xpack.slo.sloEdit.dataPreviewChart.syntheticsAvailability.xTitle', {
defaultMessage: 'Last 24 hours',
});
export const getGroupByCardinalityFilters = (
monitorIds: string[],
projects: string[],

View file

@ -13,25 +13,42 @@ import { useGetPreviewData } from '../../../hooks/use_get_preview_data';
export function useDebouncedGetPreviewData(
isIndicatorValid: boolean,
indicator: Indicator,
range: { from: Date; to: Date }
range: { from: Date; to: Date },
groupBy?: string | string[]
) {
const serializedIndicator = JSON.stringify(indicator);
const [indicatorState, setIndicatorState] = useState<string>(serializedIndicator);
const serializedGroupBy = JSON.stringify([groupBy].flat());
const [groupByState, setGroupByState] = useState<string>(serializedGroupBy);
// eslint-disable-next-line react-hooks/exhaustive-deps
const store = useCallback(
debounce((value: string) => setIndicatorState(value), 800),
[]
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const storeGroupBy = useCallback(
debounce((value: string) => setGroupByState(value), 800),
[]
);
useEffect(() => {
if (indicatorState !== serializedIndicator) {
store(serializedIndicator);
}
}, [indicatorState, serializedIndicator, store]);
useEffect(() => {
if (groupByState !== serializedGroupBy) {
storeGroupBy(serializedGroupBy);
}
}, [groupByState, serializedGroupBy, storeGroupBy]);
return useGetPreviewData({
isValid: isIndicatorValid,
indicator: JSON.parse(indicatorState),
range,
groupBy: JSON.parse(groupByState),
});
}

View file

@ -0,0 +1,335 @@
/*
* 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 { ElasticsearchClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { dataViewsService } from '@kbn/data-views-plugin/server/mocks';
import { GetPreviewDataParams } from '@kbn/slo-schema';
import { GetPreviewData } from './get_preview_data';
import { oneMinute } from './fixtures/duration';
describe('GetPreviewData', () => {
let esClientMock: ElasticsearchClientMock;
let service: GetPreviewData;
beforeEach(() => {
esClientMock = elasticsearchServiceMock.createElasticsearchClient();
service = new GetPreviewData(esClientMock, 'default', dataViewsService);
});
describe("for 'Custom KQL' indicator type", () => {
const params: GetPreviewDataParams = {
indicator: {
type: 'sli.kql.custom',
params: {
index: 'kbn-data-forge-fake_stack.admin-console-*',
filter: 'http.response.status_code :*',
good: 'http.response.status_code <500',
total: 'http.response.status_code :*',
timestampField: '@timestamp',
dataViewId: 'e7744dbe-a7a4-457b-83aa-539e9c88764c',
},
},
range: {
from: new Date('2025-02-23T14:26:49.056Z'),
to: new Date('2025-02-24T14:26:49.056Z'),
},
};
it('builds the query without groups', async () => {
await service.execute({ ...params, groupBy: ['*'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query with 1 group', async () => {
await service.execute({ ...params, groupBy: ['host.name'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query with 2 groups', async () => {
await service.execute({ ...params, groupBy: ['host.name', 'event.action'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query for a set of groupings', async () => {
await service.execute({ ...params, groupings: { 'host.name': 'host.001', env: 'prod' } });
expect(esClientMock.search).toMatchSnapshot();
});
});
describe("for 'Custom Metric' indicator type", () => {
const params: GetPreviewDataParams = {
indicator: {
type: 'sli.metric.custom',
params: {
index: 'kbn-data-forge-fake_stack.message_processor-*',
filter: '',
good: {
metrics: [
{
name: 'A',
aggregation: 'sum',
field: 'processor.processed',
filter: '',
},
],
equation: 'A',
},
total: {
metrics: [
{
name: 'A',
aggregation: 'sum',
field: 'processor.accepted',
filter: '',
},
],
equation: 'A',
},
timestampField: '@timestamp',
dataViewId: '593f894a-3378-42cc-bafc-61b4877b64b0',
},
},
range: {
from: new Date('2025-02-23T14:26:49.056Z'),
to: new Date('2025-02-24T14:26:49.056Z'),
},
};
it('builds the query without groups', async () => {
await service.execute({ ...params, groupBy: ['*'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query with 1 group', async () => {
await service.execute({ ...params, groupBy: ['host.name'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query with 2 groups', async () => {
await service.execute({ ...params, groupBy: ['host.name', 'event.action'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query for a set of groupings', async () => {
await service.execute({ ...params, groupings: { 'host.name': 'host.001', env: 'prod' } });
expect(esClientMock.search).toMatchSnapshot();
});
});
describe("for 'Custom Histogram' indicator type", () => {
const params: GetPreviewDataParams = {
indicator: {
type: 'sli.histogram.custom',
params: {
index: 'kbn-data-forge-fake_stack.message_processor-*',
timestampField: '@timestamp',
filter: '',
good: {
field: 'processor.latency',
aggregation: 'range',
filter: '',
from: 0,
to: 100,
},
total: {
field: 'processor.latency',
aggregation: 'value_count',
filter: '',
},
dataViewId: '593f894a-3378-42cc-bafc-61b4877b64b0',
},
},
range: {
from: new Date('2025-02-23T14:26:49.056Z'),
to: new Date('2025-02-24T14:26:49.056Z'),
},
};
it('builds the query without groups', async () => {
await service.execute({ ...params, groupBy: ['*'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query with 1 group', async () => {
await service.execute({ ...params, groupBy: ['host.name'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query with 2 groups', async () => {
await service.execute({ ...params, groupBy: ['host.name', 'event.action'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query for a set of groupings', async () => {
await service.execute({ ...params, groupings: { 'host.name': 'host.001', env: 'prod' } });
expect(esClientMock.search).toMatchSnapshot();
});
});
describe("for 'Timeslice Metric' indicator type", () => {
const params: GetPreviewDataParams = {
indicator: {
type: 'sli.metric.timeslice',
params: {
index: 'kbn-data-forge-fake_stack.message_processor-*',
filter: '',
metric: {
metrics: [
{
name: 'A',
aggregation: 'sum',
field: 'processor.timeSpent',
filter: '',
},
{
name: 'B',
aggregation: 'sum',
field: 'processor.processed',
filter: '',
},
],
equation: 'A / B / 1000',
comparator: 'LTE',
threshold: 180,
},
timestampField: '@timestamp',
dataViewId: '593f894a-3378-42cc-bafc-61b4877b64b0',
},
},
objective: {
target: 0.99,
timesliceTarget: 0.95,
timesliceWindow: oneMinute(),
},
range: {
from: new Date('2025-02-23T14:26:49.056Z'),
to: new Date('2025-02-24T14:26:49.056Z'),
},
};
it('builds the query without groups', async () => {
await service.execute({ ...params, groupBy: ['*'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query with 1 group', async () => {
await service.execute({ ...params, groupBy: ['host.name'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query with 2 groups', async () => {
await service.execute({ ...params, groupBy: ['host.name', 'event.action'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query for a set of groupings', async () => {
await service.execute({ ...params, groupings: { 'host.name': 'host.001', env: 'prod' } });
expect(esClientMock.search).toMatchSnapshot();
});
});
describe("for 'APM Latency' indicator type", () => {
const params: GetPreviewDataParams = {
indicator: {
type: 'sli.apm.transactionDuration',
params: {
service: 'frontend',
environment: 'prod',
transactionType: 'request',
transactionName: 'GET /api',
threshold: 250,
filter: 'some.lable:foo',
index:
'remote_cluster:apm-*,remote_cluster:metrics-apm*,remote_cluster:metrics-*.otel-*,apm-*,metrics-apm*,metrics-*.otel-*',
},
},
range: {
from: new Date('2025-02-23T14:26:49.056Z'),
to: new Date('2025-02-24T14:26:49.056Z'),
},
};
it('builds the query without groups', async () => {
await service.execute({ ...params, groupBy: ['*'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query with 1 group', async () => {
await service.execute({ ...params, groupBy: ['host.name'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query with 2 groups', async () => {
await service.execute({ ...params, groupBy: ['host.name', 'event.action'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query for a set of groupings', async () => {
await service.execute({ ...params, groupings: { 'host.name': 'host.001', env: 'prod' } });
expect(esClientMock.search).toMatchSnapshot();
});
});
describe("for 'APM Availability' indicator type", () => {
const params: GetPreviewDataParams = {
indicator: {
type: 'sli.apm.transactionErrorRate',
params: {
service: 'frontend',
environment: 'prod',
transactionType: 'request',
transactionName: 'GET /api',
filter: 'some.lable:bar',
index:
'remote_cluster:apm-*,remote_cluster:metrics-apm*,remote_cluster:metrics-*.otel-*,apm-*,metrics-apm*,metrics-*.otel-*',
},
},
range: {
from: new Date('2025-02-23T14:26:49.056Z'),
to: new Date('2025-02-24T14:26:49.056Z'),
},
};
it('builds the query without groups', async () => {
await service.execute({ ...params, groupBy: ['*'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query with 1 group', async () => {
await service.execute({ ...params, groupBy: ['host.name'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query with 2 groups', async () => {
await service.execute({ ...params, groupBy: ['host.name', 'event.action'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query for a set of groupings', async () => {
await service.execute({ ...params, groupings: { 'host.name': 'host.001', env: 'prod' } });
expect(esClientMock.search).toMatchSnapshot();
});
});
describe("for 'Synthetics' indicator type", () => {
const params: GetPreviewDataParams = {
indicator: {
type: 'sli.synthetics.availability',
params: {
monitorIds: [
{ value: 'monitor-1', label: 'My monitor 1' },
{ value: 'monitor-2', label: 'My monitor 2' },
],
index: 'synthetics-*',
projects: [{ value: 'project-1', label: 'Project 1' }],
tags: [{ value: 'tag-1', label: 'Tag 1' }],
dataViewId: '593f894a-3378-42cc-bafc-61b4877b64b0',
},
},
range: {
from: new Date('2025-02-23T14:26:49.056Z'),
to: new Date('2025-02-24T14:26:49.056Z'),
},
};
it('builds the query without groups', async () => {
await service.execute({ ...params, groupBy: ['*'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query with 1 group', async () => {
await service.execute({ ...params, groupBy: ['host.name'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query with 2 groups', async () => {
await service.execute({ ...params, groupBy: ['host.name', 'event.action'] });
expect(esClientMock.search).toMatchSnapshot();
});
it('builds the query for a set of groupings', async () => {
await service.execute({ ...params, groupings: { 'host.name': 'host.001', env: 'prod' } });
expect(esClientMock.search).toMatchSnapshot();
});
});
});

View file

@ -5,35 +5,35 @@
* 2.0.
*/
import { estypes } from '@elastic/elasticsearch';
import { AggregationsAggregationContainer } from '@elastic/elasticsearch/lib/api/types';
import { calculateAuto } from '@kbn/calculate-auto';
import { ElasticsearchClient } from '@kbn/core/server';
import { DataView, DataViewsService } from '@kbn/data-views-plugin/common';
import {
ALL_VALUE,
APMTransactionErrorRateIndicator,
SyntheticsAvailabilityIndicator,
GetPreviewDataParams,
GetPreviewDataResponse,
HistogramIndicator,
KQLCustomIndicator,
MetricCustomIndicator,
SyntheticsAvailabilityIndicator,
TimesliceMetricIndicator,
} from '@kbn/slo-schema';
import { assertNever } from '@kbn/std';
import moment from 'moment';
import { ElasticsearchClient } from '@kbn/core/server';
import { estypes } from '@elastic/elasticsearch';
import { DataView, DataViewsService } from '@kbn/data-views-plugin/common';
import { getElasticsearchQueryOrThrow } from './transform_generators';
import { buildParamValues } from './transform_generators/synthetics_availability';
import { typedSearch } from '../utils/queries';
import { APMTransactionDurationIndicator } from '../domain/models';
import { SYNTHETICS_INDEX_PATTERN } from '../../common/constants';
import { APMTransactionDurationIndicator, Groupings } from '../domain/models';
import { computeSLIForPreview } from '../domain/services';
import { typedSearch } from '../utils/queries';
import {
GetCustomMetricIndicatorAggregation,
GetHistogramIndicatorAggregation,
GetTimesliceMetricIndicatorAggregation,
} from './aggregations';
import { SYNTHETICS_INDEX_PATTERN } from '../../common/constants';
import { getElasticsearchQueryOrThrow } from './transform_generators';
import { buildParamValues } from './transform_generators/synthetics_availability';
interface Options {
range: {
@ -41,11 +41,11 @@ interface Options {
end: number;
};
interval: string;
instanceId?: string;
remoteName?: string;
groupBy?: string | string[];
groupings?: Record<string, unknown>;
groupings?: Groupings;
groupBy?: string[];
}
export class GetPreviewData {
constructor(
private esClient: ElasticsearchClient,
@ -53,7 +53,7 @@ export class GetPreviewData {
private dataViewService: DataViewsService
) {}
public async buildRuntimeMappings({ dataViewId }: { dataViewId?: string }) {
private async buildRuntimeMappings({ dataViewId }: { dataViewId?: string }) {
let dataView: DataView | undefined;
if (dataViewId) {
try {
@ -65,12 +65,45 @@ export class GetPreviewData {
return dataView?.getRuntimeMappings?.() ?? {};
}
private addExtraTermsOrMultiTermsAgg(
perInterval: AggregationsAggregationContainer,
groupBy?: string[]
): Record<string, AggregationsAggregationContainer> {
if (!groupBy || groupBy.length === 0) return { perInterval };
if (groupBy.length === 1) {
return {
perGroup: {
terms: {
size: 5,
field: groupBy[0],
},
aggs: { perInterval },
},
perInterval,
};
}
return {
perGroup: {
multi_terms: {
size: 5,
terms: groupBy.map((group) => ({ field: group })),
},
aggs: { perInterval },
},
perInterval,
};
}
private async getAPMTransactionDurationPreviewData(
indicator: APMTransactionDurationIndicator,
options: Options
): Promise<GetPreviewDataResponse> {
const filter: estypes.QueryDslQueryContainer[] = [];
this.getGroupingsFilter(options, filter);
const groupingFilters = this.getGroupingFilters(options);
if (groupingFilters) {
filter.push(...groupingFilters);
}
if (indicator.params.service !== ALL_VALUE)
filter.push({
match: { 'service.name': indicator.params.service },
@ -96,7 +129,7 @@ export class GetPreviewData {
? `${options.remoteName}:${indicator.params.index}`
: indicator.params.index;
const result = await typedSearch(this.esClient, {
const response = await typedSearch(this.esClient, {
index,
runtime_mappings: await this.buildRuntimeMappings({
dataViewId: indicator.params.dataViewId,
@ -113,8 +146,8 @@ export class GetPreviewData {
],
},
},
aggs: {
perMinute: {
aggs: this.addExtraTermsOrMultiTermsAgg(
{
date_histogram: {
field: '@timestamp',
fixed_interval: options.interval,
@ -146,12 +179,14 @@ export class GetPreviewData {
},
},
},
},
options.groupBy
),
});
return (
result.aggregations?.perMinute.buckets.map((bucket) => {
const good = (bucket.good?.value as number) ?? 0;
const results =
// @ts-ignore
response.aggregations?.perInterval.buckets.map((bucket) => {
const good = bucket.good?.value ?? 0;
const total = bucket.total?.value ?? 0;
return {
date: bucket.key_as_string,
@ -162,8 +197,28 @@ export class GetPreviewData {
bad: total - good,
},
};
}) ?? []
);
}) ?? [];
// @ts-ignore
const groups = response.aggregations?.perGroup?.buckets?.reduce((acc, group) => {
// @ts-ignore
acc[group.key] = group.perInterval.buckets.map((bucket) => {
const good = bucket.good?.value ?? 0;
const total = bucket.total?.value ?? 0;
return {
date: bucket.key_as_string,
sliValue: computeSLIForPreview(good, total),
events: {
good,
bad: total - good,
total,
},
};
});
return acc;
}, {});
return { results, groups };
}
private async getAPMTransactionErrorPreviewData(
@ -171,7 +226,10 @@ export class GetPreviewData {
options: Options
): Promise<GetPreviewDataResponse> {
const filter: estypes.QueryDslQueryContainer[] = [];
this.getGroupingsFilter(options, filter);
const groupingFilters = this.getGroupingFilters(options);
if (groupingFilters) {
filter.push(...groupingFilters);
}
if (indicator.params.service !== ALL_VALUE)
filter.push({
match: { 'service.name': indicator.params.service },
@ -195,7 +253,7 @@ export class GetPreviewData {
? `${options.remoteName}:${indicator.params.index}`
: indicator.params.index;
const result = await this.esClient.search({
const response = await typedSearch(this.esClient, {
index,
runtime_mappings: await this.buildRuntimeMappings({
dataViewId: indicator.params.dataViewId,
@ -211,8 +269,8 @@ export class GetPreviewData {
],
},
},
aggs: {
perMinute: {
aggs: this.addExtraTermsOrMultiTermsAgg(
{
date_histogram: {
field: '@timestamp',
fixed_interval: options.interval,
@ -240,22 +298,45 @@ export class GetPreviewData {
},
},
},
},
options.groupBy
),
});
// @ts-ignore buckets is not improperly typed
return result.aggregations?.perMinute.buckets.map((bucket) => ({
date: bucket.key_as_string,
sliValue:
!!bucket.good && !!bucket.total
? computeSLIForPreview(bucket.good.doc_count, bucket.total.doc_count)
: null,
events: {
good: bucket.good?.doc_count ?? 0,
bad: (bucket.total?.doc_count ?? 0) - (bucket.good?.doc_count ?? 0),
total: bucket.total?.doc_count ?? 0,
},
}));
const results =
// @ts-ignore
response.aggregations?.perInterval.buckets.map((bucket) => {
const good = bucket.good?.doc_count ?? 0;
const total = bucket.total?.doc_count ?? 0;
return {
date: bucket.key_as_string,
sliValue: computeSLIForPreview(good, total),
events: {
good,
bad: total - good,
total,
},
};
}) ?? [];
// @ts-ignore
const groups = response.aggregations?.perGroup?.buckets?.reduce((acc, group) => {
// @ts-ignore
acc[group.key] = group.perInterval.buckets.map((bucket) => {
const good = bucket.good?.doc_count ?? 0;
const total = bucket.total?.doc_count ?? 0;
return {
date: bucket.key_as_string,
sliValue: computeSLIForPreview(good, total),
events: {
good,
bad: total - good,
total,
},
};
});
return acc;
}, {});
return { results, groups };
}
private async getHistogramPreviewData(
@ -271,13 +352,16 @@ export class GetPreviewData {
filterQuery,
];
this.getGroupingsFilter(options, filter);
const groupingFilters = this.getGroupingFilters(options);
if (groupingFilters) {
filter.push(...groupingFilters);
}
const index = options.remoteName
? `${options.remoteName}:${indicator.params.index}`
: indicator.params.index;
const result = await this.esClient.search({
const response = await this.esClient.search({
index,
runtime_mappings: await this.buildRuntimeMappings({
dataViewId: indicator.params.dataViewId,
@ -288,8 +372,8 @@ export class GetPreviewData {
filter,
},
},
aggs: {
perMinute: {
aggs: this.addExtraTermsOrMultiTermsAgg(
{
date_histogram: {
field: timestampField,
fixed_interval: options.interval,
@ -309,22 +393,52 @@ export class GetPreviewData {
}),
},
},
},
options.groupBy
),
});
// @ts-ignore buckets is not improperly typed
return result.aggregations?.perMinute.buckets.map((bucket) => ({
date: bucket.key_as_string,
sliValue:
!!bucket.good && !!bucket.total
? computeSLIForPreview(bucket.good.value, bucket.total.value)
: null,
events: {
good: bucket.good?.value ?? 0,
bad: (bucket.total?.value ?? 0) - (bucket.good?.value ?? 0),
total: bucket.total?.value ?? 0,
},
}));
interface Bucket {
key_as_string: string;
good: { value: number };
total: { value: number };
}
const results =
// @ts-ignore buckets not typed properly
response.aggregations?.perInterval.buckets.map((bucket: Bucket) => {
const good = bucket.good?.value ?? 0;
const total = bucket.total?.value ?? 0;
return {
date: bucket.key_as_string,
sliValue: computeSLIForPreview(good, total),
events: {
good,
bad: total - good,
total,
},
};
}) ?? [];
// @ts-ignore
const groups = response.aggregations?.perGroup?.buckets?.reduce((acc, group) => {
// @ts-ignore
acc[group.key] = group.perInterval.buckets.map((bucket) => {
const good = bucket.good?.value ?? 0;
const total = bucket.total?.value ?? 0;
return {
date: bucket.key_as_string,
sliValue: computeSLIForPreview(good, total),
events: {
good,
bad: total - good,
total,
},
};
});
return acc;
}, {});
return { results, groups };
}
private async getCustomMetricPreviewData(
@ -339,13 +453,17 @@ export class GetPreviewData {
{ range: { [timestampField]: { gte: options.range.start, lte: options.range.end } } },
filterQuery,
];
this.getGroupingsFilter(options, filter);
const groupingFilters = this.getGroupingFilters(options);
if (groupingFilters) {
filter.push(...groupingFilters);
}
const index = options.remoteName
? `${options.remoteName}:${indicator.params.index}`
: indicator.params.index;
const result = await this.esClient.search({
const response = await this.esClient.search({
index,
runtime_mappings: await this.buildRuntimeMappings({
dataViewId: indicator.params.dataViewId,
@ -356,8 +474,8 @@ export class GetPreviewData {
filter,
},
},
aggs: {
perMinute: {
aggs: this.addExtraTermsOrMultiTermsAgg(
{
date_histogram: {
field: timestampField,
fixed_interval: options.interval,
@ -377,22 +495,52 @@ export class GetPreviewData {
}),
},
},
},
options.groupBy
),
});
// @ts-ignore buckets is not improperly typed
return result.aggregations?.perMinute.buckets.map((bucket) => ({
date: bucket.key_as_string,
sliValue:
!!bucket.good && !!bucket.total
? computeSLIForPreview(bucket.good.value, bucket.total.value)
: null,
events: {
good: bucket.good?.value ?? 0,
bad: (bucket.total?.value ?? 0) - (bucket.good?.value ?? 0),
total: bucket.total?.value ?? 0,
},
}));
interface Bucket {
key_as_string: string;
good: { value: number };
total: { value: number };
}
const results =
// @ts-ignore buckets not typed properly
response.aggregations?.perInterval.buckets.map((bucket: Bucket) => {
const good = bucket.good?.value ?? 0;
const total = bucket.total?.value ?? 0;
return {
date: bucket.key_as_string,
sliValue: computeSLIForPreview(good, total),
events: {
good,
bad: total - good,
total,
},
};
}) ?? [];
// @ts-ignore
const groups = response.aggregations?.perGroup?.buckets?.reduce((acc, group) => {
// @ts-ignore
acc[group.key] = group.perInterval.buckets.map((bucket) => {
const good = bucket.good?.value ?? 0;
const total = bucket.total?.value ?? 0;
return {
date: bucket.key_as_string,
sliValue: computeSLIForPreview(good, total),
events: {
good,
bad: total - good,
total,
},
};
});
return acc;
}, {});
return { results, groups };
}
private async getTimesliceMetricPreviewData(
@ -410,13 +558,16 @@ export class GetPreviewData {
filterQuery,
];
this.getGroupingsFilter(options, filter);
const groupingFilters = this.getGroupingFilters(options);
if (groupingFilters) {
filter.push(...groupingFilters);
}
const index = options.remoteName
? `${options.remoteName}:${indicator.params.index}`
: indicator.params.index;
const result = await this.esClient.search({
const response = await this.esClient.search({
index,
runtime_mappings: await this.buildRuntimeMappings({
dataViewId: indicator.params.dataViewId,
@ -427,8 +578,8 @@ export class GetPreviewData {
filter,
},
},
aggs: {
perMinute: {
aggs: this.addExtraTermsOrMultiTermsAgg(
{
date_histogram: {
field: timestampField,
fixed_interval: options.interval,
@ -437,18 +588,39 @@ export class GetPreviewData {
max: options.range.end,
},
},
aggs: {
...getCustomMetricIndicatorAggregation.execute('metric'),
},
aggs: getCustomMetricIndicatorAggregation.execute('metric'),
},
},
options.groupBy
),
});
// @ts-ignore buckets is not improperly typed
return result.aggregations?.perMinute.buckets.map((bucket) => ({
date: bucket.key_as_string,
sliValue: !!bucket.metric ? bucket.metric.value : null,
}));
interface Bucket {
key_as_string: string;
metric: { value: number };
}
const results =
// @ts-ignore buckets not typed properly
response.aggregations?.perInterval.buckets.map((bucket: Bucket) => {
return {
date: bucket.key_as_string,
sliValue: bucket.metric?.value ?? null,
};
}) ?? [];
// @ts-ignore
const groups = response.aggregations?.perGroup?.buckets?.reduce((acc, group) => {
// @ts-ignore
acc[group.key] = group.perInterval.buckets.map((bucket) => {
return {
date: bucket.key_as_string,
sliValue: bucket.metric?.value ?? null,
};
});
return acc;
}, {});
return { results, groups };
}
private async getCustomKQLPreviewData(
@ -464,13 +636,16 @@ export class GetPreviewData {
filterQuery,
];
this.getGroupingsFilter(options, filter);
const groupingFilters = this.getGroupingFilters(options);
if (groupingFilters) {
filter.push(...groupingFilters);
}
const index = options.remoteName
? `${options.remoteName}:${indicator.params.index}`
: indicator.params.index;
const result = await this.esClient.search({
const response = await typedSearch(this.esClient, {
index,
runtime_mappings: await this.buildRuntimeMappings({
dataViewId: indicator.params.dataViewId,
@ -481,8 +656,8 @@ export class GetPreviewData {
filter,
},
},
aggs: {
perMinute: {
aggs: this.addExtraTermsOrMultiTermsAgg(
{
date_histogram: {
field: timestampField,
fixed_interval: options.interval,
@ -496,41 +671,52 @@ export class GetPreviewData {
total: { filter: totalQuery },
},
},
},
options.groupBy
),
});
// @ts-ignore buckets is not improperly typed
return result.aggregations?.perMinute.buckets.map((bucket) => ({
date: bucket.key_as_string,
sliValue:
!!bucket.good && !!bucket.total
? computeSLIForPreview(bucket.good.doc_count, bucket.total.doc_count)
: null,
events: {
good: bucket.good?.doc_count ?? 0,
bad: (bucket.total?.doc_count ?? 0) - (bucket.good?.doc_count ?? 0),
total: bucket.total?.doc_count ?? 0,
},
}));
const results =
// @ts-ignore
response.aggregations?.perInterval.buckets.map((bucket) => {
const good = bucket.good?.doc_count ?? 0;
const total = bucket.total?.doc_count ?? 0;
return {
date: bucket.key_as_string,
sliValue: computeSLIForPreview(good, total),
events: {
good,
bad: total - good,
total,
},
};
}) ?? [];
// @ts-ignore
const groups = response.aggregations?.perGroup?.buckets?.reduce((acc, group) => {
// @ts-ignore
acc[group.key] = group.perInterval.buckets.map((bucket) => {
const good = bucket.good?.doc_count ?? 0;
const total = bucket.total?.doc_count ?? 0;
return {
date: bucket.key_as_string,
sliValue: computeSLIForPreview(good, total),
events: {
good,
bad: total - good,
total,
},
};
});
return acc;
}, {});
return { results, groups };
}
private getGroupingsFilter(options: Options, filter: estypes.QueryDslQueryContainer[]) {
const groupingsKeys = Object.keys(options.groupings || []);
private getGroupingFilters(options: Options): estypes.QueryDslQueryContainer[] | undefined {
const groupingsKeys = Object.keys(options.groupings ?? {});
if (groupingsKeys.length) {
groupingsKeys.forEach((key) => {
filter.push({
term: { [key]: options.groupings?.[key] },
});
});
} else if (options.instanceId && options.instanceId !== ALL_VALUE && options.groupBy) {
const instanceIdPart = options.instanceId.split(',');
const groupByPart = Array.isArray(options.groupBy) ? options.groupBy : [options.groupBy];
groupByPart.forEach((groupBy, index) => {
filter.push({
term: { [groupBy]: instanceIdPart[index] },
});
});
return groupingsKeys.map((key) => ({ term: { [key]: options.groupings![key] } }));
}
}
@ -561,7 +747,7 @@ export class GetPreviewData {
? `${options.remoteName}:${SYNTHETICS_INDEX_PATTERN}`
: SYNTHETICS_INDEX_PATTERN;
const result = await this.esClient.search({
const response = await typedSearch(this.esClient, {
index,
runtime_mappings: await this.buildRuntimeMappings({
dataViewId: indicator.params.dataViewId,
@ -577,11 +763,15 @@ export class GetPreviewData {
],
},
},
aggs: {
perMinute: {
aggs: this.addExtraTermsOrMultiTermsAgg(
{
date_histogram: {
field: '@timestamp',
fixed_interval: '10m',
fixed_interval: options.interval,
extended_bounds: {
min: options.range.start,
max: options.range.end,
},
},
aggs: {
good: {
@ -591,13 +781,6 @@ export class GetPreviewData {
},
},
},
bad: {
filter: {
term: {
'monitor.status': 'down',
},
},
},
total: {
filter: {
match_all: {},
@ -605,53 +788,69 @@ export class GetPreviewData {
},
},
},
},
options.groupBy
),
});
const data: GetPreviewDataResponse = [];
const results =
// @ts-ignore
response.aggregations?.perInterval.buckets.map((bucket) => {
const good = bucket.good?.doc_count ?? 0;
const total = bucket.total?.doc_count ?? 0;
return {
date: bucket.key_as_string,
sliValue: computeSLIForPreview(good, total),
events: {
good,
bad: total - good,
total,
},
};
}) ?? [];
// @ts-ignore buckets is not improperly typed
result.aggregations?.perMinute.buckets.forEach((bucket) => {
const good = bucket.good?.doc_count ?? 0;
const bad = bucket.bad?.doc_count ?? 0;
const total = bucket.total?.doc_count ?? 0;
data.push({
date: bucket.key_as_string,
sliValue: computeSLIForPreview(good, total),
events: {
good,
bad,
total,
},
// @ts-ignore
const groups = response.aggregations?.perGroup?.buckets?.reduce((acc, group) => {
// @ts-ignore
acc[group.key] = group.perInterval.buckets.map((bucket) => {
const good = bucket.good?.doc_count ?? 0;
const total = bucket.total?.doc_count ?? 0;
return {
date: bucket.key_as_string,
sliValue: computeSLIForPreview(good, total),
events: {
good,
bad: total - good,
total,
},
};
});
});
return acc;
}, {});
return data;
return { results, groups };
}
public async execute(params: GetPreviewDataParams): Promise<GetPreviewDataResponse> {
try {
// 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.
// If the time range is 24h or less, then we want to use the timeslice duration for the buckets
// 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 rangeDuration = moment(params.range.to).diff(params.range.from, 'ms');
const bucketSize =
params.indicator.type === 'sli.metric.timeslice' &&
rangeDuration <= 86_400_000 &&
params.objective?.timesliceWindow
rangeDuration <= 86_400_000 && params.objective?.timesliceWindow
? params.objective.timesliceWindow.asMinutes()
: Math.max(
calculateAuto.near(100, moment.duration(rangeDuration, 'ms'))?.asMinutes() ?? 0,
1
);
const options: Options = {
instanceId: params.instanceId,
range: { start: params.range.from.getTime(), end: params.range.to.getTime() },
groupBy: params.groupBy,
remoteName: params.remoteName,
groupings: params.groupings,
interval: `${bucketSize}m`,
groupBy: params.groupBy?.filter((value) => value !== ALL_VALUE),
};
const type = params.indicator.type;
@ -674,7 +873,7 @@ export class GetPreviewData {
assertNever(type);
}
} catch (err) {
return [];
return { results: [] };
}
}
}