feat(slo): Show SLI preview chart for custom kql (#159713)

This commit is contained in:
Kevin Delemme 2023-06-15 18:39:21 -04:00 committed by GitHub
parent fa98aa4f8c
commit f9d16e160b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 335 additions and 29 deletions

View file

@ -6,24 +6,22 @@
*/
import * as t from 'io-ts';
import {
budgetingMethodSchema,
dateType,
historicalSummarySchema,
indicatorSchema,
indicatorTypesArraySchema,
kqlCustomIndicatorSchema,
metricCustomIndicatorSchema,
objectiveSchema,
optionalSettingsSchema,
previewDataSchema,
settingsSchema,
sloIdSchema,
summarySchema,
tagsSchema,
timeWindowSchema,
metricCustomIndicatorSchema,
kqlCustomIndicatorSchema,
apmTransactionErrorRateIndicatorSchema,
apmTransactionDurationIndicatorSchema,
} from '../schema';
const createSLOParamsSchema = t.type({
@ -44,6 +42,14 @@ const createSLOResponseSchema = t.type({
id: sloIdSchema,
});
const getPreviewDataParamsSchema = t.type({
body: t.type({
indicator: indicatorSchema,
}),
});
const getPreviewDataResponseSchema = t.array(previewDataSchema);
const deleteSLOParamsSchema = t.type({
path: t.type({
id: sloIdSchema,
@ -156,20 +162,22 @@ type FetchHistoricalSummaryParams = t.TypeOf<typeof fetchHistoricalSummaryParams
type FetchHistoricalSummaryResponse = t.OutputOf<typeof fetchHistoricalSummaryResponseSchema>;
type HistoricalSummaryResponse = t.OutputOf<typeof historicalSummarySchema>;
type GetPreviewDataParams = t.TypeOf<typeof getPreviewDataParamsSchema.props.body>;
type GetPreviewDataResponse = t.TypeOf<typeof getPreviewDataResponseSchema>;
type BudgetingMethod = t.TypeOf<typeof budgetingMethodSchema>;
type MetricCustomIndicatorSchema = t.TypeOf<typeof metricCustomIndicatorSchema>;
type KQLCustomIndicatorSchema = t.TypeOf<typeof kqlCustomIndicatorSchema>;
type APMTransactionErrorRateIndicatorSchema = t.TypeOf<
typeof apmTransactionErrorRateIndicatorSchema
>;
type APMTransactionDurationIndicatorSchema = t.TypeOf<typeof apmTransactionDurationIndicatorSchema>;
type Indicator = t.OutputOf<typeof indicatorSchema>;
type MetricCustomIndicator = t.OutputOf<typeof metricCustomIndicatorSchema>;
type KQLCustomIndicator = t.OutputOf<typeof kqlCustomIndicatorSchema>;
export {
createSLOParamsSchema,
deleteSLOParamsSchema,
findSLOParamsSchema,
findSLOResponseSchema,
getPreviewDataParamsSchema,
getPreviewDataResponseSchema,
getSLODiagnosisParamsSchema,
getSLOParamsSchema,
getSLOResponseSchema,
@ -188,6 +196,8 @@ export type {
CreateSLOResponse,
FindSLOParams,
FindSLOResponse,
GetPreviewDataParams,
GetPreviewDataResponse,
GetSLOResponse,
FetchHistoricalSummaryParams,
FetchHistoricalSummaryResponse,
@ -198,8 +208,7 @@ export type {
UpdateSLOInput,
UpdateSLOParams,
UpdateSLOResponse,
MetricCustomIndicatorSchema,
KQLCustomIndicatorSchema,
APMTransactionDurationIndicatorSchema,
APMTransactionErrorRateIndicatorSchema,
Indicator,
MetricCustomIndicator,
KQLCustomIndicator,
};

View file

@ -52,6 +52,11 @@ const historicalSummarySchema = t.intersection([
summarySchema,
]);
const previewDataSchema = t.type({
date: dateType,
sliValue: t.number,
});
const dateRangeSchema = t.type({ from: dateType, to: dateType });
export type { SummarySchema };
@ -63,6 +68,7 @@ export {
dateType,
errorBudgetSchema,
historicalSummarySchema,
previewDataSchema,
statusSchema,
summarySchema,
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { KQLCustomIndicatorSchema, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { KQLCustomIndicator, SLOWithSummaryResponse } from '@kbn/slo-schema';
export const buildApmAvailabilityIndicator = (
params: Partial<SLOWithSummaryResponse['indicator']['params']> = {}
@ -53,5 +53,5 @@ export const buildCustomKqlIndicator = (
timestampField: '@timestamp',
...params,
},
} as KQLCustomIndicatorSchema;
} as KQLCustomIndicator;
};

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import type { Indicator } from '@kbn/slo-schema';
interface SloKeyFilter {
name: string;
page: number;
@ -31,6 +33,7 @@ export const sloKeys = {
historicalSummaries: () => [...sloKeys.all, 'historicalSummary'] as const,
historicalSummary: (sloIds: string[]) => [...sloKeys.historicalSummaries(), sloIds] as const,
globalDiagnosis: () => [...sloKeys.all, 'globalDiagnosis'] as const,
preview: (indicator?: Indicator) => [...sloKeys.all, 'preview', indicator] as const,
};
export const compositeSloKeys = {

View file

@ -0,0 +1,62 @@
/*
* 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, Indicator } from '@kbn/slo-schema';
import {
QueryObserverResult,
RefetchOptions,
RefetchQueryFilters,
useQuery,
} from '@tanstack/react-query';
import { useKibana } from '../../utils/kibana_react';
import { sloKeys } from './query_key_factory';
export interface UseGetPreviewData {
data: GetPreviewDataResponse | undefined;
isInitialLoading: boolean;
isRefetching: boolean;
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
refetch: <TPageData>(
options?: (RefetchOptions & RefetchQueryFilters<TPageData>) | undefined
) => Promise<QueryObserverResult<GetPreviewDataResponse | undefined, unknown>>;
}
export function useGetPreviewData(indicator?: Indicator): UseGetPreviewData {
const { http } = useKibana().services;
const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data, refetch } = useQuery(
{
queryKey: sloKeys.preview(indicator),
queryFn: async ({ signal }) => {
const response = await http.post<GetPreviewDataResponse>(
'/internal/observability/slos/_preview',
{
body: JSON.stringify({ indicator }),
signal,
}
);
return response;
},
retry: false,
refetchOnWindowFocus: false,
enabled: Boolean(indicator),
}
);
return {
data,
isLoading,
isRefetching,
isInitialLoading,
isSuccess,
isError,
refetch,
};
}

View file

@ -0,0 +1,105 @@
/*
* 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 { AreaSeries, Axis, Chart, Position, ScaleType, Settings } from '@elastic/charts';
import { EuiFlexItem, EuiIcon, EuiLoadingChart, EuiPanel } from '@elastic/eui';
import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
import { CreateSLOInput } from '@kbn/slo-schema';
import moment from 'moment';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { useKibana } from '../../../../utils/kibana_react';
import { useDebouncedGetPreviewData } from '../../hooks/use_preview';
export function DataPreviewChart() {
const { watch, getFieldState } = useFormContext<CreateSLOInput>();
const { charts, uiSettings } = useKibana().services;
const { data: previewData, isLoading: isPreviewLoading } = useDebouncedGetPreviewData(
watch('indicator')
);
const theme = charts.theme.useChartsTheme();
const baseTheme = charts.theme.useChartsBaseTheme();
const dateFormat = uiSettings.get('dateFormat');
const percentFormat = uiSettings.get('format:percent:defaultPattern');
if (getFieldState('indicator').invalid) {
return null;
}
return (
<EuiFlexItem>
{isPreviewLoading && <EuiLoadingChart size="m" mono />}
{!isPreviewLoading && !!previewData && (
<EuiPanel hasBorder={true} hasShadow={false}>
<Chart size={{ height: 160, width: '100%' }}>
<Settings
baseTheme={baseTheme}
showLegend={false}
theme={[
{
...theme,
lineSeriesStyle: {
point: { visible: false },
},
},
]}
tooltip="vertical"
noResults={
<EuiIcon type="visualizeApp" size="l" color="subdued" title="no results" />
}
/>
<Axis
id="y-axis"
title={i18n.translate('xpack.observability.slo.sloEdit.dataPreviewChart.yTitle', {
defaultMessage: 'SLI',
})}
ticks={5}
position={Position.Left}
tickFormat={(d) => numeral(d).format(percentFormat)}
/>
<Axis
id="time"
title={i18n.translate('xpack.observability.slo.sloEdit.dataPreviewChart.xTitle', {
defaultMessage: 'Last hour',
})}
tickFormat={(d) => moment(d).format(dateFormat)}
position={Position.Bottom}
timeAxisLayerCount={2}
gridLine={{ visible: true }}
style={{
tickLine: { size: 0.0001, padding: 4, visible: true },
tickLabel: {
alignment: {
horizontal: Position.Left,
vertical: Position.Bottom,
},
padding: 0,
offset: { x: 0, y: 0 },
},
}}
/>
<AreaSeries
id="SLI"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="date"
yAccessors={['value']}
data={previewData.map((datum) => ({
date: new Date(datum.date).getTime(),
value: datum.sliValue >= 0 ? datum.sliValue : null,
}))}
/>
</Chart>
</EuiPanel>
)}
</EuiFlexItem>
);
}

View file

@ -21,6 +21,7 @@ import {
Field,
useFetchIndexPatternFields,
} from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { DataPreviewChart } from '../common/data_preview_chart';
import { QueryBuilder } from '../common/query_builder';
import { IndexSelection } from '../custom_common/index_selection';
@ -31,7 +32,6 @@ interface Option {
export function CustomKqlIndicatorTypeForm() {
const { control, watch, getFieldState } = useFormContext<CreateSLOInput>();
const { isLoading, data: indexFields } = useFetchIndexPatternFields(
watch('indicator.params.index')
);
@ -86,12 +86,7 @@ export function CustomKqlIndicatorTypeForm() {
!!watch('indicator.params.index') &&
!!field.value &&
timestampFields.some((timestampField) => timestampField.name === field.value)
? [
{
value: field.value,
label: field.value,
},
]
? [{ value: field.value, label: field.value }]
: []
}
singleSelection={{ asPlainText: true }}
@ -187,6 +182,8 @@ export function CustomKqlIndicatorTypeForm() {
}
/>
</EuiFlexItem>
<DataPreviewChart />
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,29 @@
/*
* 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 { Indicator } from '@kbn/slo-schema';
import { debounce } from 'lodash';
import { useCallback, useEffect, useState } from 'react';
import { useGetPreviewData } from '../../../hooks/slo/use_get_preview_data';
export function useDebouncedGetPreviewData(indicator: Indicator) {
const serializedIndicator = JSON.stringify(indicator);
const [indicatorState, setIndicatorState] = useState<string>(serializedIndicator);
// eslint-disable-next-line react-hooks/exhaustive-deps
const store = useCallback(
debounce((value: string) => setIndicatorState(value), 800),
[]
);
useEffect(() => {
if (indicatorState !== serializedIndicator) {
store(serializedIndicator);
}
}, [indicatorState, serializedIndicator, store]);
return useGetPreviewData(JSON.parse(indicatorState));
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { CreateSLOInput, MetricCustomIndicatorSchema } from '@kbn/slo-schema';
import { CreateSLOInput, MetricCustomIndicator } from '@kbn/slo-schema';
import { FormState, UseFormGetFieldState, UseFormGetValues, UseFormWatch } from 'react-hook-form';
import { isObject } from 'lodash';
@ -22,9 +22,7 @@ export function useSectionFormValidation({ getFieldState, getValues, formState,
switch (watch('indicator.type')) {
case 'sli.metric.custom':
const isGoodParamsValid = () => {
const data = getValues(
'indicator.params.good'
) as MetricCustomIndicatorSchema['params']['good'];
const data = getValues('indicator.params.good') as MetricCustomIndicator['params']['good'];
const isEquationValid = !getFieldState('indicator.params.good.equation').invalid;
const areMetricsValid =
isObject(data) && (data.metrics ?? []).every((metric) => Boolean(metric.field));
@ -34,7 +32,7 @@ export function useSectionFormValidation({ getFieldState, getValues, formState,
const isTotalParamsValid = () => {
const data = getValues(
'indicator.params.total'
) as MetricCustomIndicatorSchema['params']['total'];
) as MetricCustomIndicator['params']['total'];
const isEquationValid = !getFieldState('indicator.params.total.equation').invalid;
const areMetricsValid =
isObject(data) && (data.metrics ?? []).every((metric) => Boolean(metric.field));

View file

@ -66,6 +66,12 @@ const mockKibana = () => {
application: {
navigateToUrl: mockNavigate,
},
charts: {
theme: {
useChartsTheme: () => {},
useChartsBaseTheme: () => {},
},
},
data: {
dataViews: {
find: jest.fn().mockReturnValue([]),

View file

@ -20,6 +20,7 @@ export class SLOIdConflict extends ObservabilityError {}
export class CompositeSLONotFound extends ObservabilityError {}
export class CompositeSLOIdConflict extends ObservabilityError {}
export class InvalidQueryError extends ObservabilityError {}
export class InternalQueryError extends ObservabilityError {}
export class NotSupportedError extends ObservabilityError {}
export class IllegalArgumentError extends ObservabilityError {}

View file

@ -11,6 +11,7 @@ import {
deleteSLOParamsSchema,
fetchHistoricalSummaryParamsSchema,
findSLOParamsSchema,
getPreviewDataParamsSchema,
getSLODiagnosisParamsSchema,
getSLOParamsSchema,
manageSLOParamsSchema,
@ -41,6 +42,7 @@ import type { IndicatorTypes } from '../../domain/models';
import type { ObservabilityRequestHandlerContext } from '../../types';
import { ManageSLO } from '../../services/slo/manage_slo';
import { getGlobalDiagnosis, getSloDiagnosis } from '../../services/slo/get_diagnosis';
import { GetPreviewData } from '../../services/slo/get_preview_data';
const transformGenerators: Record<IndicatorTypes, TransformGenerator> = {
'sli.apm.transactionDuration': new ApmTransactionDurationTransformGenerator(),
@ -303,6 +305,25 @@ const getSloDiagnosisRoute = createObservabilityServerRoute({
},
});
const getPreviewData = createObservabilityServerRoute({
endpoint: 'POST /internal/observability/slos/_preview',
options: {
tags: ['access:slo_read'],
},
params: getPreviewDataParamsSchema,
handler: async ({ context, params }) => {
const hasCorrectLicense = await isLicenseAtLeastPlatinum(context);
if (!hasCorrectLicense) {
throw badRequest('Platinum license or higher is needed to make use of this feature.');
}
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const service = new GetPreviewData(esClient);
return await service.execute(params.body);
},
});
export const sloRouteRepository = {
...createSLORoute,
...deleteSLORoute,
@ -314,4 +335,5 @@ export const sloRouteRepository = {
...updateSLORoute,
...getDiagnosisRoute,
...getSloDiagnosisRoute,
...getPreviewData,
};

View file

@ -0,0 +1,68 @@
/*
* 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 { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
import { GetPreviewDataParams, GetPreviewDataResponse } from '@kbn/slo-schema';
import { computeSLI } from '../../domain/services';
import { InvalidQueryError } from '../../errors';
export class GetPreviewData {
constructor(private esClient: ElasticsearchClient) {}
public async execute(params: GetPreviewDataParams): Promise<GetPreviewDataResponse> {
switch (params.indicator.type) {
case 'sli.kql.custom':
const filterQuery = getElastichsearchQueryOrThrow(params.indicator.params.filter);
const goodQuery = getElastichsearchQueryOrThrow(params.indicator.params.good);
const totalQuery = getElastichsearchQueryOrThrow(params.indicator.params.total);
const timestampField = params.indicator.params.timestampField;
try {
const result = await this.esClient.search({
index: params.indicator.params.index,
query: {
bool: {
filter: [{ range: { [timestampField]: { gte: 'now-60m' } } }, filterQuery],
},
},
aggs: {
perMinute: {
date_histogram: {
field: timestampField,
fixed_interval: '1m',
},
aggs: {
good: { filter: goodQuery },
total: { filter: totalQuery },
},
},
},
});
// @ts-ignore buckets is not improperly typed
return result.aggregations?.perMinute.buckets.map((bucket) => ({
date: bucket.key_as_string,
sliValue: computeSLI(bucket.good.doc_count, bucket.total.doc_count),
}));
} catch (err) {
throw new InvalidQueryError(`Invalid ES query`);
}
default:
return [];
}
}
}
function getElastichsearchQueryOrThrow(kuery: string) {
try {
return toElasticsearchQuery(fromKueryExpression(kuery));
} catch (err) {
throw new InvalidQueryError(`Invalid kuery: ${kuery}`);
}
}