diff --git a/x-pack/packages/kbn-slo-schema/src/rest_specs/slo.ts b/x-pack/packages/kbn-slo-schema/src/rest_specs/slo.ts index f8f1d60edb7ad..9d37c36c94534 100644 --- a/x-pack/packages/kbn-slo-schema/src/rest_specs/slo.ts +++ b/x-pack/packages/kbn-slo-schema/src/rest_specs/slo.ts @@ -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; type HistoricalSummaryResponse = t.OutputOf; +type GetPreviewDataParams = t.TypeOf; +type GetPreviewDataResponse = t.TypeOf; + type BudgetingMethod = t.TypeOf; -type MetricCustomIndicatorSchema = t.TypeOf; -type KQLCustomIndicatorSchema = t.TypeOf; -type APMTransactionErrorRateIndicatorSchema = t.TypeOf< - typeof apmTransactionErrorRateIndicatorSchema ->; -type APMTransactionDurationIndicatorSchema = t.TypeOf; +type Indicator = t.OutputOf; +type MetricCustomIndicator = t.OutputOf; +type KQLCustomIndicator = t.OutputOf; 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, }; diff --git a/x-pack/packages/kbn-slo-schema/src/schema/common.ts b/x-pack/packages/kbn-slo-schema/src/schema/common.ts index f702540bf20c7..250525ce2192c 100644 --- a/x-pack/packages/kbn-slo-schema/src/schema/common.ts +++ b/x-pack/packages/kbn-slo-schema/src/schema/common.ts @@ -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, }; diff --git a/x-pack/plugins/observability/public/data/slo/indicator.ts b/x-pack/plugins/observability/public/data/slo/indicator.ts index 62ff1c0b8287f..227ddf89fc667 100644 --- a/x-pack/plugins/observability/public/data/slo/indicator.ts +++ b/x-pack/plugins/observability/public/data/slo/indicator.ts @@ -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 = {} @@ -53,5 +53,5 @@ export const buildCustomKqlIndicator = ( timestampField: '@timestamp', ...params, }, - } as KQLCustomIndicatorSchema; + } as KQLCustomIndicator; }; diff --git a/x-pack/plugins/observability/public/hooks/slo/query_key_factory.ts b/x-pack/plugins/observability/public/hooks/slo/query_key_factory.ts index 73bdd9528dd75..f4c557105ce62 100644 --- a/x-pack/plugins/observability/public/hooks/slo/query_key_factory.ts +++ b/x-pack/plugins/observability/public/hooks/slo/query_key_factory.ts @@ -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 = { diff --git a/x-pack/plugins/observability/public/hooks/slo/use_get_preview_data.ts b/x-pack/plugins/observability/public/hooks/slo/use_get_preview_data.ts new file mode 100644 index 0000000000000..89d1fe4e5ef8c --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/slo/use_get_preview_data.ts @@ -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: ( + options?: (RefetchOptions & RefetchQueryFilters) | undefined + ) => Promise>; +} + +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( + '/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, + }; +} diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx new file mode 100644 index 0000000000000..3f99a9f7638b8 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx @@ -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(); + 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 ( + + {isPreviewLoading && } + {!isPreviewLoading && !!previewData && ( + + + + } + /> + + numeral(d).format(percentFormat)} + /> + + 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 }, + }, + }} + /> + ({ + date: new Date(datum.date).getTime(), + value: datum.sliValue >= 0 ? datum.sliValue : null, + }))} + /> + + + )} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/custom_kql/custom_kql_indicator_type_form.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/custom_kql/custom_kql_indicator_type_form.tsx index c0b8ee400efb9..4f3ecc301e08a 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/components/custom_kql/custom_kql_indicator_type_form.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/custom_kql/custom_kql_indicator_type_form.tsx @@ -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(); - 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() { } /> + + ); } diff --git a/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_preview.ts b/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_preview.ts new file mode 100644 index 0000000000000..47d32b8d9ad3f --- /dev/null +++ b/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_preview.ts @@ -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(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)); +} diff --git a/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_section_form_validation.ts b/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_section_form_validation.ts index 1f1eeca035425..4187590b8688b 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_section_form_validation.ts +++ b/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_section_form_validation.ts @@ -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)); diff --git a/x-pack/plugins/observability/public/pages/slo_edit/slo_edit.test.tsx b/x-pack/plugins/observability/public/pages/slo_edit/slo_edit.test.tsx index 520f719447db9..4f55f8121579e 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/slo_edit.test.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/slo_edit.test.tsx @@ -66,6 +66,12 @@ const mockKibana = () => { application: { navigateToUrl: mockNavigate, }, + charts: { + theme: { + useChartsTheme: () => {}, + useChartsBaseTheme: () => {}, + }, + }, data: { dataViews: { find: jest.fn().mockReturnValue([]), diff --git a/x-pack/plugins/observability/server/errors/errors.ts b/x-pack/plugins/observability/server/errors/errors.ts index 7ac7290385dde..dbbb873925636 100644 --- a/x-pack/plugins/observability/server/errors/errors.ts +++ b/x-pack/plugins/observability/server/errors/errors.ts @@ -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 {} diff --git a/x-pack/plugins/observability/server/routes/slo/route.ts b/x-pack/plugins/observability/server/routes/slo/route.ts index 849630d368aa5..b65216f227407 100644 --- a/x-pack/plugins/observability/server/routes/slo/route.ts +++ b/x-pack/plugins/observability/server/routes/slo/route.ts @@ -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 = { '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, }; diff --git a/x-pack/plugins/observability/server/services/slo/get_preview_data.ts b/x-pack/plugins/observability/server/services/slo/get_preview_data.ts new file mode 100644 index 0000000000000..e71d7e6398577 --- /dev/null +++ b/x-pack/plugins/observability/server/services/slo/get_preview_data.ts @@ -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 { + 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}`); + } +}