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

View file

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

View file

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

View file

@ -5,6 +5,8 @@
* 2.0. * 2.0.
*/ */
import type { Indicator } from '@kbn/slo-schema';
interface SloKeyFilter { interface SloKeyFilter {
name: string; name: string;
page: number; page: number;
@ -31,6 +33,7 @@ export const sloKeys = {
historicalSummaries: () => [...sloKeys.all, 'historicalSummary'] as const, historicalSummaries: () => [...sloKeys.all, 'historicalSummary'] as const,
historicalSummary: (sloIds: string[]) => [...sloKeys.historicalSummaries(), sloIds] as const, historicalSummary: (sloIds: string[]) => [...sloKeys.historicalSummaries(), sloIds] as const,
globalDiagnosis: () => [...sloKeys.all, 'globalDiagnosis'] as const, globalDiagnosis: () => [...sloKeys.all, 'globalDiagnosis'] as const,
preview: (indicator?: Indicator) => [...sloKeys.all, 'preview', indicator] as const,
}; };
export const compositeSloKeys = { 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, Field,
useFetchIndexPatternFields, useFetchIndexPatternFields,
} from '../../../../hooks/slo/use_fetch_index_pattern_fields'; } from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { DataPreviewChart } from '../common/data_preview_chart';
import { QueryBuilder } from '../common/query_builder'; import { QueryBuilder } from '../common/query_builder';
import { IndexSelection } from '../custom_common/index_selection'; import { IndexSelection } from '../custom_common/index_selection';
@ -31,7 +32,6 @@ interface Option {
export function CustomKqlIndicatorTypeForm() { export function CustomKqlIndicatorTypeForm() {
const { control, watch, getFieldState } = useFormContext<CreateSLOInput>(); const { control, watch, getFieldState } = useFormContext<CreateSLOInput>();
const { isLoading, data: indexFields } = useFetchIndexPatternFields( const { isLoading, data: indexFields } = useFetchIndexPatternFields(
watch('indicator.params.index') watch('indicator.params.index')
); );
@ -86,12 +86,7 @@ export function CustomKqlIndicatorTypeForm() {
!!watch('indicator.params.index') && !!watch('indicator.params.index') &&
!!field.value && !!field.value &&
timestampFields.some((timestampField) => timestampField.name === field.value) timestampFields.some((timestampField) => timestampField.name === field.value)
? [ ? [{ value: field.value, label: field.value }]
{
value: field.value,
label: field.value,
},
]
: [] : []
} }
singleSelection={{ asPlainText: true }} singleSelection={{ asPlainText: true }}
@ -187,6 +182,8 @@ export function CustomKqlIndicatorTypeForm() {
} }
/> />
</EuiFlexItem> </EuiFlexItem>
<DataPreviewChart />
</EuiFlexGroup> </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. * 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 { FormState, UseFormGetFieldState, UseFormGetValues, UseFormWatch } from 'react-hook-form';
import { isObject } from 'lodash'; import { isObject } from 'lodash';
@ -22,9 +22,7 @@ export function useSectionFormValidation({ getFieldState, getValues, formState,
switch (watch('indicator.type')) { switch (watch('indicator.type')) {
case 'sli.metric.custom': case 'sli.metric.custom':
const isGoodParamsValid = () => { const isGoodParamsValid = () => {
const data = getValues( const data = getValues('indicator.params.good') as MetricCustomIndicator['params']['good'];
'indicator.params.good'
) as MetricCustomIndicatorSchema['params']['good'];
const isEquationValid = !getFieldState('indicator.params.good.equation').invalid; const isEquationValid = !getFieldState('indicator.params.good.equation').invalid;
const areMetricsValid = const areMetricsValid =
isObject(data) && (data.metrics ?? []).every((metric) => Boolean(metric.field)); isObject(data) && (data.metrics ?? []).every((metric) => Boolean(metric.field));
@ -34,7 +32,7 @@ export function useSectionFormValidation({ getFieldState, getValues, formState,
const isTotalParamsValid = () => { const isTotalParamsValid = () => {
const data = getValues( const data = getValues(
'indicator.params.total' 'indicator.params.total'
) as MetricCustomIndicatorSchema['params']['total']; ) as MetricCustomIndicator['params']['total'];
const isEquationValid = !getFieldState('indicator.params.total.equation').invalid; const isEquationValid = !getFieldState('indicator.params.total.equation').invalid;
const areMetricsValid = const areMetricsValid =
isObject(data) && (data.metrics ?? []).every((metric) => Boolean(metric.field)); isObject(data) && (data.metrics ?? []).every((metric) => Boolean(metric.field));

View file

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

View file

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

View file

@ -11,6 +11,7 @@ import {
deleteSLOParamsSchema, deleteSLOParamsSchema,
fetchHistoricalSummaryParamsSchema, fetchHistoricalSummaryParamsSchema,
findSLOParamsSchema, findSLOParamsSchema,
getPreviewDataParamsSchema,
getSLODiagnosisParamsSchema, getSLODiagnosisParamsSchema,
getSLOParamsSchema, getSLOParamsSchema,
manageSLOParamsSchema, manageSLOParamsSchema,
@ -41,6 +42,7 @@ import type { IndicatorTypes } from '../../domain/models';
import type { ObservabilityRequestHandlerContext } from '../../types'; import type { ObservabilityRequestHandlerContext } from '../../types';
import { ManageSLO } from '../../services/slo/manage_slo'; import { ManageSLO } from '../../services/slo/manage_slo';
import { getGlobalDiagnosis, getSloDiagnosis } from '../../services/slo/get_diagnosis'; import { getGlobalDiagnosis, getSloDiagnosis } from '../../services/slo/get_diagnosis';
import { GetPreviewData } from '../../services/slo/get_preview_data';
const transformGenerators: Record<IndicatorTypes, TransformGenerator> = { const transformGenerators: Record<IndicatorTypes, TransformGenerator> = {
'sli.apm.transactionDuration': new ApmTransactionDurationTransformGenerator(), '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 = { export const sloRouteRepository = {
...createSLORoute, ...createSLORoute,
...deleteSLORoute, ...deleteSLORoute,
@ -314,4 +335,5 @@ export const sloRouteRepository = {
...updateSLORoute, ...updateSLORoute,
...getDiagnosisRoute, ...getDiagnosisRoute,
...getSloDiagnosisRoute, ...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}`);
}
}