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 9d514af3606e7..6debc89ede1f1 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 @@ -11,6 +11,7 @@ import { allOrAnyString, apmTransactionDurationIndicatorSchema, apmTransactionErrorRateIndicatorSchema, + syntheticsAvailabilityIndicatorSchema, budgetingMethodSchema, dateType, durationType, @@ -21,6 +22,7 @@ import { indicatorTypesSchema, kqlCustomIndicatorSchema, metricCustomIndicatorSchema, + metaSchema, timesliceMetricIndicatorSchema, objectiveSchema, optionalSettingsSchema, @@ -151,7 +153,10 @@ const sloResponseSchema = t.intersection([ const sloWithSummaryResponseSchema = t.intersection([ sloResponseSchema, - t.type({ summary: summarySchema, groupings: groupingsSchema }), + t.intersection([ + t.type({ summary: summarySchema, groupings: groupingsSchema }), + t.partial({ meta: metaSchema }), + ]), ]); const sloGroupWithSummaryResponseSchema = t.type({ @@ -335,6 +340,7 @@ type Indicator = t.OutputOf; type Objective = t.OutputOf; type APMTransactionErrorRateIndicator = t.OutputOf; type APMTransactionDurationIndicator = t.OutputOf; +type SyntheticsAvailabilityIndicator = t.OutputOf; type MetricCustomIndicator = t.OutputOf; type TimesliceMetricIndicator = t.OutputOf; type TimesliceMetricBasicMetricWithField = t.OutputOf; @@ -406,6 +412,7 @@ export type { UpdateSLOResponse, APMTransactionDurationIndicator, APMTransactionErrorRateIndicator, + SyntheticsAvailabilityIndicator, GetSLOBurnRatesResponse, GetSLOInstancesResponse, IndicatorType, 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 d3424eaed8842..350caed40d2d4 100644 --- a/x-pack/packages/kbn-slo-schema/src/schema/common.ts +++ b/x-pack/packages/kbn-slo-schema/src/schema/common.ts @@ -51,6 +51,14 @@ const summarySchema = t.type({ const groupingsSchema = t.record(t.string, t.union([t.string, t.number])); +const metaSchema = t.partial({ + synthetics: t.type({ + monitorId: t.string, + locationId: t.string, + configId: t.string, + }), +}); + const groupSummarySchema = t.type({ total: t.number, worst: t.type({ @@ -132,6 +140,7 @@ export { previewDataSchema, statusSchema, summarySchema, + metaSchema, groupSummarySchema, kqlWithFiltersSchema, querySchema, diff --git a/x-pack/packages/kbn-slo-schema/src/schema/indicators.ts b/x-pack/packages/kbn-slo-schema/src/schema/indicators.ts index 3c8ef0cd12af9..189857be733f7 100644 --- a/x-pack/packages/kbn-slo-schema/src/schema/indicators.ts +++ b/x-pack/packages/kbn-slo-schema/src/schema/indicators.ts @@ -222,6 +222,26 @@ const histogramIndicatorSchema = t.type({ ]), }); +const syntheticsParamSchema = t.type({ + value: allOrAnyString, + label: allOrAnyString, +}); +const syntheticsAvailabilityIndicatorTypeSchema = t.literal('sli.synthetics.availability'); +const syntheticsAvailabilityIndicatorSchema = t.type({ + type: syntheticsAvailabilityIndicatorTypeSchema, + params: t.intersection([ + t.type({ + monitorIds: t.array(syntheticsParamSchema), + index: t.string, + }), + t.partial({ + tags: t.array(syntheticsParamSchema), + projects: t.array(syntheticsParamSchema), + filter: querySchema, + }), + ]), +}); + const indicatorDataSchema = t.type({ dateRange: dateRangeSchema, good: t.number, @@ -231,6 +251,7 @@ const indicatorDataSchema = t.type({ const indicatorTypesSchema = t.union([ apmTransactionDurationIndicatorTypeSchema, apmTransactionErrorRateIndicatorTypeSchema, + syntheticsAvailabilityIndicatorTypeSchema, kqlCustomIndicatorTypeSchema, metricCustomIndicatorTypeSchema, timesliceMetricIndicatorTypeSchema, @@ -259,6 +280,7 @@ const indicatorTypesArraySchema = new t.Type( const indicatorSchema = t.union([ apmTransactionDurationIndicatorSchema, apmTransactionErrorRateIndicatorSchema, + syntheticsAvailabilityIndicatorSchema, kqlCustomIndicatorSchema, metricCustomIndicatorSchema, timesliceMetricIndicatorSchema, @@ -270,6 +292,8 @@ export { apmTransactionDurationIndicatorTypeSchema, apmTransactionErrorRateIndicatorSchema, apmTransactionErrorRateIndicatorTypeSchema, + syntheticsAvailabilityIndicatorSchema, + syntheticsAvailabilityIndicatorTypeSchema, kqlCustomIndicatorSchema, kqlCustomIndicatorTypeSchema, metricCustomIndicatorSchema, diff --git a/x-pack/plugins/observability_solution/observability/common/slo/constants.ts b/x-pack/plugins/observability_solution/observability/common/slo/constants.ts index 7ef3ebb292e4d..f98586bf0aea7 100644 --- a/x-pack/plugins/observability_solution/observability/common/slo/constants.ts +++ b/x-pack/plugins/observability_solution/observability/common/slo/constants.ts @@ -40,3 +40,5 @@ export const getSLOSummaryTransformId = (sloId: string, sloRevision: number) => export const getSLOSummaryPipelineId = (sloId: string, sloRevision: number) => `.slo-observability.summary.pipeline-${sloId}-${sloRevision}`; + +export const SYNTHETICS_INDEX_PATTERN = 'synthetics-*'; diff --git a/x-pack/plugins/observability_solution/observability/public/data/slo/slo.ts b/x-pack/plugins/observability_solution/observability/public/data/slo/slo.ts index 7ae37c3ca0793..31ec29c44a31d 100644 --- a/x-pack/plugins/observability_solution/observability/public/data/slo/slo.ts +++ b/x-pack/plugins/observability_solution/observability/public/data/slo/slo.ts @@ -70,6 +70,7 @@ const baseSlo: Omit = { createdAt: now, updatedAt: now, version: 2, + meta: {}, }; export const sloList: FindSLOResponse = { diff --git a/x-pack/plugins/observability_solution/observability/public/hooks/slo/use_fetch_synthetics_suggestions.ts b/x-pack/plugins/observability_solution/observability/public/hooks/slo/use_fetch_synthetics_suggestions.ts new file mode 100644 index 0000000000000..8510f5d059fea --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/public/hooks/slo/use_fetch_synthetics_suggestions.ts @@ -0,0 +1,76 @@ +/* + * 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 { useQuery } from '@tanstack/react-query'; + +import { useKibana } from '../../utils/kibana_react'; + +export interface Suggestion { + label: string; + value: string; + count: number; +} + +export interface UseFetchSyntheticsSuggestions { + suggestions: Suggestion[]; + isLoading: boolean; + isSuccess: boolean; + isError: boolean; +} + +export interface Params { + fieldName: string; + filters?: { + locations?: string[]; + monitorIds?: string[]; + tags?: string[]; + projects?: string[]; + }; + search: string; +} + +type ApiResponse = Record; + +export function useFetchSyntheticsSuggestions({ + filters, + fieldName, + search, +}: Params): UseFetchSyntheticsSuggestions { + const { http } = useKibana().services; + const { locations, monitorIds, tags, projects } = filters || {}; + + const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({ + queryKey: ['fetchSyntheticsSuggestions', locations, monitorIds, tags, projects, search], + queryFn: async ({ signal }) => { + try { + const suggestions = await http.get('/internal/synthetics/suggestions', { + query: { + locations: locations || [], + monitorQueryIds: monitorIds || [], + tags: tags || [], + projects: projects || [], + query: search, + }, + signal, + }); + + return suggestions; + } catch (error) { + // ignore error + } + }, + refetchOnWindowFocus: false, + keepPreviousData: true, + }); + + return { + suggestions: isInitialLoading ? [] : data?.[fieldName] ?? [], + isLoading: isInitialLoading || isLoading || isRefetching, + isSuccess, + isError, + }; +} diff --git a/x-pack/plugins/observability_solution/observability/public/locators/slo_edit.test.ts b/x-pack/plugins/observability_solution/observability/public/locators/slo_edit.test.ts index aad10aac2cd50..c75b1100fa6ff 100644 --- a/x-pack/plugins/observability_solution/observability/public/locators/slo_edit.test.ts +++ b/x-pack/plugins/observability_solution/observability/public/locators/slo_edit.test.ts @@ -20,7 +20,7 @@ describe('SloEditLocator', () => { it('should return correct url when slo is provided', async () => { const location = await locator.getLocation(buildSlo({ id: 'foo' })); expect(location.path).toEqual( - "/slos/edit/foo?_a=(budgetingMethod:occurrences,createdAt:'2022-12-29T10:11:12.000Z',description:'some%20description%20useful',enabled:!t,groupBy:'*',groupings:(),id:foo,indicator:(params:(filter:'baz:%20foo%20and%20bar%20%3E%202',good:'http_status:%202xx',index:some-index,timestampField:custom_timestamp,total:'a%20query'),type:sli.kql.custom),instanceId:'*',name:'super%20important%20level%20service',objective:(target:0.98),revision:1,settings:(frequency:'1m',syncDelay:'1m'),summary:(errorBudget:(consumed:0.064,initial:0.02,isEstimated:!f,remaining:0.936),sliValue:0.99872,status:HEALTHY),tags:!(k8s,production,critical),timeWindow:(duration:'30d',type:rolling),updatedAt:'2022-12-29T10:11:12.000Z',version:2)" + "/slos/edit/foo?_a=(budgetingMethod:occurrences,createdAt:'2022-12-29T10:11:12.000Z',description:'some%20description%20useful',enabled:!t,groupBy:'*',groupings:(),id:foo,indicator:(params:(filter:'baz:%20foo%20and%20bar%20%3E%202',good:'http_status:%202xx',index:some-index,timestampField:custom_timestamp,total:'a%20query'),type:sli.kql.custom),instanceId:'*',meta:(),name:'super%20important%20level%20service',objective:(target:0.98),revision:1,settings:(frequency:'1m',syncDelay:'1m'),summary:(errorBudget:(consumed:0.064,initial:0.02,isEstimated:!f,remaining:0.936),sliValue:0.99872,status:HEALTHY),tags:!(k8s,production,critical),timeWindow:(duration:'30d',type:rolling),updatedAt:'2022-12-29T10:11:12.000Z',version:2)" ); }); }); diff --git a/x-pack/plugins/observability_solution/observability/public/pages/slo_details/components/events_chart_panel.tsx b/x-pack/plugins/observability_solution/observability/public/pages/slo_details/components/events_chart_panel.tsx index e024e332b58dd..96b0f0b29e998 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/slo_details/components/events_chart_panel.tsx +++ b/x-pack/plugins/observability_solution/observability/public/pages/slo_details/components/events_chart_panel.tsx @@ -8,9 +8,7 @@ import { AnnotationDomainType, AreaSeries, Axis, - BarSeries, Chart, - ElementClickListener, LineAnnotation, Position, RectAnnotation, @@ -18,7 +16,6 @@ import { Settings, Tooltip, TooltipType, - XYChartElementEvent, } from '@elastic/charts'; import { EuiFlexGroup, @@ -42,7 +39,8 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { useGetPreviewData } from '../../../hooks/slo/use_get_preview_data'; import { useKibana } from '../../../utils/kibana_react'; import { COMPARATOR_MAPPING } from '../../slo_edit/constants'; -import { openInDiscover, getDiscoverLink } from '../../../utils/slo/get_discover_link'; +import { GoodBadEventsChart } from '../../slos/components/common/good_bad_events_chart'; +import { getDiscoverLink } from '../../../utils/slo/get_discover_link'; export interface Props { slo: SLOWithSummaryResponse; @@ -112,11 +110,6 @@ export function EventsChartPanel({ slo, range }: Props) { threshold != null && maxValue != null && threshold > maxValue ? threshold : maxValue || NaN, }; - const intervalInMilliseconds = - data && data.length > 2 - ? moment(data[1].date).valueOf() - moment(data[0].date).valueOf() - : 10 * 60000; - const annotation = slo.indicator.type === 'sli.metric.timeslice' && threshold ? ( <> @@ -152,29 +145,6 @@ export function EventsChartPanel({ slo, range }: Props) { ) : null; - const goodEventId = i18n.translate( - 'xpack.observability.slo.sloDetails.eventsChartPanel.goodEventsLabel', - { defaultMessage: 'Good events' } - ); - - const badEventId = i18n.translate( - 'xpack.observability.slo.sloDetails.eventsChartPanel.badEventsLabel', - { defaultMessage: 'Bad events' } - ); - - const barClickHandler = (params: XYChartElementEvent[]) => { - if (slo.indicator.type === 'sli.kql.custom') { - const [datanum, eventDetail] = params[0]; - const isBad = eventDetail.specId === badEventId; - const timeRange = { - from: moment(datanum.x).toISOString(), - to: moment(datanum.x).add(intervalInMilliseconds, 'ms').toISOString(), - mode: 'absolute' as const, - }; - openInDiscover(discover, slo, isBad, !isBad, timeRange); - } - }; - const showViewEventsLink = ![ 'sli.apm.transactionErrorRate', 'sli.apm.transactionDuration', @@ -216,99 +186,66 @@ export function EventsChartPanel({ slo, range }: Props) { - {isLoading && } - - {!isLoading && ( - - - - } - onPointerUpdate={handleCursorUpdate} - externalPointerEvents={{ - tooltip: { visible: true }, - }} - pointerUpdateDebounce={0} - pointerUpdateTrigger={'x'} - locale={i18n.getLocale()} - onElementClick={barClickHandler as ElementClickListener} - /> - {annotation} - - moment(d).format(dateFormat)} - /> - numeral(d).format(yAxisNumberFormat)} - domain={domain} - /> + {slo.indicator.type !== 'sli.metric.timeslice' ? ( + + ) : ( + <> + {isLoading && ( + + )} - {slo.indicator.type !== 'sli.metric.timeslice' ? ( - <> - ({ - key: new Date(datum.date).getTime(), - value: datum.events?.good, - })) ?? [] + {!isLoading && ( + + + } + onPointerUpdate={handleCursorUpdate} + externalPointerEvents={{ + tooltip: { visible: true }, + }} + pointerUpdateDebounce={0} + pointerUpdateTrigger={'x'} + locale={i18n.getLocale()} /> + {annotation} - moment(d).format(dateFormat)} + /> + numeral(d).format(yAxisNumberFormat)} + domain={domain} + /> + ({ - key: new Date(datum.date).getTime(), - value: datum.events?.bad, - })) ?? [] - } + data={(data ?? []).map((datum) => ({ + date: new Date(datum.date).getTime(), + value: datum.sliValue >= 0 ? datum.sliValue : null, + }))} /> - - ) : ( - ({ - date: new Date(datum.date).getTime(), - value: datum.sliValue >= 0 ? datum.sliValue : null, - }))} - /> + )} - + )} diff --git a/x-pack/plugins/observability_solution/observability/public/pages/slo_details/components/overview/overview.tsx b/x-pack/plugins/observability_solution/observability/public/pages/slo_details/components/overview/overview.tsx index c041c0a8d5003..303fc6d66a3eb 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/slo_details/components/overview/overview.tsx +++ b/x-pack/plugins/observability_solution/observability/public/pages/slo_details/components/overview/overview.tsx @@ -26,6 +26,7 @@ import { toIndicatorTypeLabel, } from '../../../../utils/slo/labels'; import { ApmIndicatorOverview } from './apm_indicator_overview'; +import { SyntheticsIndicatorOverview } from './synthetics_indicator_overview'; import { OverviewItem } from './overview_item'; @@ -45,6 +46,8 @@ export function Overview({ slo }: Props) { case 'sli.apm.transactionErrorRate': IndicatorOverview = ; break; + case 'sli.synthetics.availability': + IndicatorOverview = ; } return ( diff --git a/x-pack/plugins/observability_solution/observability/public/pages/slo_details/components/overview/synthetics_indicator_overview.tsx b/x-pack/plugins/observability_solution/observability/public/pages/slo_details/components/overview/synthetics_indicator_overview.tsx new file mode 100644 index 0000000000000..6c7c97e0e64d7 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/public/pages/slo_details/components/overview/synthetics_indicator_overview.tsx @@ -0,0 +1,93 @@ +/* + * 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 { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { syntheticsAvailabilityIndicatorSchema, SLOWithSummaryResponse } from '@kbn/slo-schema'; +import React from 'react'; +import { useKibana } from '../../../../utils/kibana_react'; +import { OverviewItem } from './overview_item'; +import { syntheticsMonitorDetailLocatorID } from '../../../../../common'; + +interface Props { + slo: SLOWithSummaryResponse; +} + +export function SyntheticsIndicatorOverview({ slo }: Props) { + const { + share: { + url: { locators }, + }, + } = useKibana().services; + + const locator = locators.get(syntheticsMonitorDetailLocatorID); + + const { 'monitor.name': name, 'observer.geo.name': location } = slo.groupings; + const { configId, locationId } = slo.meta?.synthetics || {}; + + const indicator = slo.indicator; + if (!syntheticsAvailabilityIndicatorSchema.is(indicator)) { + return null; + } + + const onMonitorClick = () => locator?.navigate({ configId, locationId }); + const showOverviewItem = name || location; + + if (!showOverviewItem) { + return null; + } + + return ( + + {name && ( + + + {i18n.translate( + 'xpack.observability.slo.sloDetails.overview.syntheticsMonitor.name', + { defaultMessage: 'Name: {value}', values: { value: name } } + )} + + + )} + {location && ( + + + {i18n.translate( + 'xpack.observability.slo.sloDetails.overview.syntheticsMonitor.locationName', + { defaultMessage: 'Location: {value}', values: { value: location } } + )} + + + )} + + } + /> + ); +} + +const MONITOR_LABEL = i18n.translate( + 'xpack.observability.slo.sloDetails.overview.syntheticsMonitor', + { + defaultMessage: 'Synthetics monitor', + } +); + +const MONITOR_ARIA_LABEL = i18n.translate( + 'xpack.observability.slo.sloDetails.overview.syntheticsMonitorDetails', + { + defaultMessage: 'Synthetics monitor details', + } +); diff --git a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx index fc805118d6f3a..a0a903e2de1d4 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx +++ b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx @@ -37,6 +37,7 @@ import { max, min } from 'lodash'; import moment from 'moment'; import React, { useState } from 'react'; import { useFormContext } from 'react-hook-form'; +import { GoodBadEventsChart } from '../../../slos/components/common/good_bad_events_chart'; import { useKibana } from '../../../../utils/kibana_react'; import { useDebouncedGetPreviewData } from '../../hooks/use_preview'; import { useSectionFormValidation } from '../../hooks/use_section_form_validation'; @@ -49,6 +50,12 @@ interface DataPreviewChartProps { thresholdColor?: string; thresholdMessage?: string; ignoreMoreThan100?: boolean; + useGoodBadEventsChart?: boolean; + label?: string; + range?: { + start: number; + end: number; + }; } const ONE_HOUR_IN_MILLISECONDS = 1 * 60 * 60 * 1000; @@ -60,6 +67,9 @@ export function DataPreviewChart({ thresholdColor, thresholdMessage, ignoreMoreThan100, + label, + useGoodBadEventsChart, + range, }: DataPreviewChartProps) { const { watch, getFieldState, formState, getValues } = useFormContext(); const { charts, uiSettings } = useKibana().services; @@ -70,17 +80,19 @@ export function DataPreviewChart({ watch, }); - const [range, _] = useState({ + const [defaultRange, _] = useState({ start: new Date().getTime() - ONE_HOUR_IN_MILLISECONDS, end: new Date().getTime(), }); + const indicator = watch('indicator'); + const { data: previewData, isLoading: isPreviewLoading, isSuccess, isError, - } = useDebouncedGetPreviewData(isIndicatorSectionValid, watch('indicator'), range); + } = useDebouncedGetPreviewData(isIndicatorSectionValid, indicator, range || defaultRange); const isMoreThan100 = !ignoreMoreThan100 && previewData?.find((row) => row.sliValue > 1) != null; @@ -181,7 +193,7 @@ export function DataPreviewChart({ id: 'label', type: 'custom', truncate: true, - cell: ({ label }) => {label}, + cell: ({ label: cellLabel }) => {cellLabel}, style: { textAlign: 'left', }, @@ -239,7 +251,15 @@ export function DataPreviewChart({ )} - {isSuccess && ( + {isSuccess && useGoodBadEventsChart && ( + + )} + {isSuccess && !useGoodBadEventsChart && ( moment(d).format(dateFormat)} position={Position.Bottom} timeAxisLayerCount={2} @@ -344,3 +362,7 @@ export function DataPreviewChart({ ); } + +const DEFAULT_LABEL = i18n.translate('xpack.observability.slo.sloEdit.dataPreviewChart.xTitle', { + defaultMessage: 'Last hour', +}); diff --git a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/components/slo_edit_form_indicator_section.tsx b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/components/slo_edit_form_indicator_section.tsx index eff001a751dd3..099ae458a8ca4 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/components/slo_edit_form_indicator_section.tsx +++ b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/components/slo_edit_form_indicator_section.tsx @@ -14,6 +14,7 @@ import { useUnregisterFields } from '../hooks/use_unregister_fields'; import { CreateSLOForm } from '../types'; import { ApmAvailabilityIndicatorTypeForm } from './apm_availability/apm_availability_indicator_type_form'; import { ApmLatencyIndicatorTypeForm } from './apm_latency/apm_latency_indicator_type_form'; +import { SyntheticsAvailabilityIndicatorTypeForm } from './synthetics_availability/synthetics_availability_indicator_type_form'; import { CustomKqlIndicatorTypeForm } from './custom_kql/custom_kql_indicator_type_form'; import { CustomMetricIndicatorTypeForm } from './custom_metric/custom_metric_type_form'; import { HistogramIndicatorTypeForm } from './histogram/histogram_indicator_type_form'; @@ -38,6 +39,8 @@ export function SloEditFormIndicatorSection({ isEditMode }: SloEditFormIndicator return ; case 'sli.apm.transactionErrorRate': return ; + case 'sli.synthetics.availability': + return ; case 'sli.metric.custom': return ; case 'sli.histogram.custom': diff --git a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/components/slo_edit_form_objective_section.tsx b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/components/slo_edit_form_objective_section.tsx index ad1f84183a138..073248d844fed 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/components/slo_edit_form_objective_section.tsx +++ b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/components/slo_edit_form_objective_section.tsx @@ -186,6 +186,20 @@ export function SloEditFormObjectiveSection() { )} + {indicator === 'sli.synthetics.availability' && ( + + +

+ +

+
+ +
+ )} + ( >(); + + const [monitorIds = [], projects = [], tags = [], index] = watch([ + 'indicator.params.monitorIds', + 'indicator.params.projects', + 'indicator.params.tags', + 'indicator.params.index', + ]); + + const [range, _] = useState({ + start: new Date().getTime() - ONE_DAY_IN_MILLISECONDS, + end: new Date().getTime(), + }); + + const filters = { + monitorIds: monitorIds.map((id) => id.value).filter((id) => id !== ALL_VALUE), + projects: projects.map((project) => project.value).filter((id) => id !== ALL_VALUE), + tags: tags.map((tag) => tag.value).filter((id) => id !== ALL_VALUE), + }; + + return ( + + + + } + /> + + + + + + + + } + /> + + + + + + + ); +} + +const LABEL = i18n.translate( + 'xpack.observability.slo.sloEdit.dataPreviewChart.syntheticsAvailability.xTitle', + { + defaultMessage: 'Last 24 hours', + } +); diff --git a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/components/synthetics_common/field_selector.tsx b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/components/synthetics_common/field_selector.tsx new file mode 100644 index 0000000000000..1f4b27b3b135f --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/components/synthetics_common/field_selector.tsx @@ -0,0 +1,141 @@ +/* + * 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 React, { ReactNode, useState } from 'react'; +import { omit } from 'lodash'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiFlexItem, EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ALL_VALUE, SyntheticsAvailabilityIndicator } from '@kbn/slo-schema'; +import { debounce } from 'lodash'; +import { Controller, FieldPath, useFormContext } from 'react-hook-form'; +import { OptionalText } from '../common/optional_text'; +import { + useFetchSyntheticsSuggestions, + Suggestion, +} from '../../../../hooks/slo/use_fetch_synthetics_suggestions'; +import { CreateSLOForm } from '../../types'; + +interface Option { + label: string; + value: string; +} + +export interface Props { + allowAllOption?: boolean; + dataTestSubj: string; + fieldName: 'monitorIds' | 'projects' | 'tags' | 'locations'; + label: string; + name: FieldPath>; + placeholder: string; + tooltip?: ReactNode; + suggestions?: Suggestion[]; + isLoading?: boolean; + required?: boolean; + filters: Record; +} + +export function FieldSelector({ + allowAllOption = true, + dataTestSubj, + fieldName, + label, + name, + placeholder, + tooltip, + required, + filters, +}: Props) { + const { control, getFieldState } = + useFormContext>(); + const [search, setSearch] = useState(''); + + const { suggestions = [], isLoading } = useFetchSyntheticsSuggestions({ + filters: omit(filters, fieldName), + search, + fieldName, + }); + + const debouncedSearch = debounce((value) => setSearch(value), 200); + + const ALL_VALUE_OPTION = { + value: ALL_VALUE, + label: i18n.translate('xpack.observability.slo.sloEdit.fieldSelector.all', { + defaultMessage: 'All', + }), + }; + + const options = (allowAllOption ? [ALL_VALUE_OPTION] : []).concat(createOptions(suggestions)); + + return ( + + + {label} {tooltip} + + ) : ( + label + ) + } + isInvalid={getFieldState(name).invalid} + fullWidth + labelAppend={!required ? : undefined} + > + ( + { + // removes ALL value option if a specific value is selected + if (selected.length && selected.at(-1)?.value !== ALL_VALUE) { + field.onChange(selected.filter((value) => value.value !== ALL_VALUE)); + return; + } + // removes specific value if ALL value is selected + if (selected.length && selected.at(-1)?.value === ALL_VALUE) { + field.onChange([ALL_VALUE_OPTION]); + return; + } + + field.onChange([]); + }} + onSearchChange={(value: string) => debouncedSearch(value)} + options={options} + placeholder={placeholder} + selectedOptions={ + !!Array.isArray(field.value) && field.value.length + ? (field.value as Array<{ value: string; label: string }>).map((value) => ({ + value: value.value, + label: value.label, + 'data-test-subj': `${dataTestSubj}SelectedValue`, + })) + : [] + } + /> + )} + /> + + + ); +} + +function createOptions(suggestions: Suggestion[] = []): Option[] { + return suggestions + .map((suggestion) => ({ label: suggestion.label, value: suggestion.value })) + .sort((a, b) => String(a.label).localeCompare(b.label)); +} diff --git a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/constants.ts b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/constants.ts index 9c5284855c772..9ad1e622a2fa5 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/constants.ts +++ b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/constants.ts @@ -10,6 +10,7 @@ import { ALL_VALUE, APMTransactionDurationIndicator, APMTransactionErrorRateIndicator, + SyntheticsAvailabilityIndicator, BudgetingMethod, HistogramIndicator, IndicatorType, @@ -23,11 +24,13 @@ import { BUDGETING_METHOD_TIMESLICES, INDICATOR_APM_AVAILABILITY, INDICATOR_APM_LATENCY, + INDICATOR_SYNTHETICS_AVAILABILITY, INDICATOR_CUSTOM_KQL, INDICATOR_CUSTOM_METRIC, INDICATOR_HISTOGRAM, INDICATOR_TIMESLICE_METRIC, } from '../../utils/slo/labels'; +import { SYNTHETICS_INDEX_PATTERN } from '../../../common/slo/constants'; import { CreateSLOForm } from './types'; export const SLI_OPTIONS: Array<{ @@ -58,6 +61,10 @@ export const SLI_OPTIONS: Array<{ value: 'sli.apm.transactionErrorRate', text: INDICATOR_APM_AVAILABILITY, }, + { + value: 'sli.synthetics.availability', + text: INDICATOR_SYNTHETICS_AVAILABILITY, + }, ]; export const BUDGETING_METHOD_OPTIONS: Array<{ value: BudgetingMethod; text: string }> = [ @@ -188,6 +195,16 @@ export const APM_AVAILABILITY_DEFAULT_VALUES: APMTransactionErrorRateIndicator = }, }; +export const SYNTHETICS_AVAILABILITY_DEFAULT_VALUES: SyntheticsAvailabilityIndicator = { + type: 'sli.synthetics.availability' as const, + params: { + projects: [], + tags: [], + monitorIds: [], + index: SYNTHETICS_INDEX_PATTERN, + }, +}; + export const SLO_EDIT_FORM_DEFAULT_VALUES: CreateSLOForm = { name: '', description: '', @@ -220,6 +237,22 @@ export const SLO_EDIT_FORM_DEFAULT_VALUES_CUSTOM_METRIC: CreateSLOForm = { groupBy: ALL_VALUE, }; +export const SLO_EDIT_FORM_DEFAULT_VALUES_SYNTHETICS_AVAILABILITY: CreateSLOForm = { + name: '', + description: '', + indicator: SYNTHETICS_AVAILABILITY_DEFAULT_VALUES, + timeWindow: { + duration: ROLLING_TIMEWINDOW_OPTIONS[1].value, + type: 'rolling', + }, + tags: [], + budgetingMethod: BUDGETING_METHOD_OPTIONS[0].value, + objective: { + target: 99, + }, + groupBy: ['monitor.name', 'observer.geo.name'], +}; + export const COMPARATOR_GT = i18n.translate( 'xpack.observability.slo.sloEdit.sliType.timesliceMetric.gtLabel', { diff --git a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/helpers/process_slo_form_values.ts b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/helpers/process_slo_form_values.ts index 64a0cc49d8550..fd8bb96e2d715 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/helpers/process_slo_form_values.ts +++ b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/helpers/process_slo_form_values.ts @@ -17,6 +17,8 @@ import { CUSTOM_METRIC_DEFAULT_VALUES, HISTOGRAM_DEFAULT_VALUES, SLO_EDIT_FORM_DEFAULT_VALUES, + SLO_EDIT_FORM_DEFAULT_VALUES_SYNTHETICS_AVAILABILITY, + SYNTHETICS_AVAILABILITY_DEFAULT_VALUES, TIMESLICE_METRIC_DEFAULT_VALUES, } from '../constants'; import { CreateSLOForm } from '../types'; @@ -120,6 +122,15 @@ function transformPartialIndicatorState( type: 'sli.apm.transactionErrorRate' as const, params: Object.assign({}, APM_AVAILABILITY_DEFAULT_VALUES.params, indicator.params ?? {}), }; + case 'sli.synthetics.availability': + return { + type: 'sli.synthetics.availability' as const, + params: Object.assign( + {}, + SYNTHETICS_AVAILABILITY_DEFAULT_VALUES.params, + indicator.params ?? {} + ), + }; case 'sli.histogram.custom': return { type: 'sli.histogram.custom' as const, @@ -148,9 +159,17 @@ function transformPartialIndicatorState( export function transformPartialUrlStateToFormState( values: RecursivePartial ): CreateSLOForm { - const state: CreateSLOForm = cloneDeep(SLO_EDIT_FORM_DEFAULT_VALUES); - + let state: CreateSLOForm; const indicator = transformPartialIndicatorState(values.indicator); + + switch (indicator?.type) { + case 'sli.synthetics.availability': + state = cloneDeep(SLO_EDIT_FORM_DEFAULT_VALUES_SYNTHETICS_AVAILABILITY); + break; + default: + state = cloneDeep(SLO_EDIT_FORM_DEFAULT_VALUES); + } + if (indicator !== undefined) { state.indicator = indicator; } diff --git a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/hooks/use_section_form_validation.ts b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/hooks/use_section_form_validation.ts index e8cabe602e7cc..7d75359f4cd40 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/hooks/use_section_form_validation.ts +++ b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/hooks/use_section_form_validation.ts @@ -199,6 +199,15 @@ export function useSectionFormValidation({ getFieldState, getValues, formState, (field) => !getFieldState(field, formState).invalid ); break; + case 'sli.synthetics.availability': + isIndicatorSectionValid = + (['indicator.params.monitorIds'] as const).every( + (field) => !getFieldState(field, formState).invalid && getValues(field)?.length + ) && + ( + ['indicator.params.index', 'indicator.params.tags', 'indicator.params.projects'] as const + ).every((field) => !getFieldState(field, formState).invalid); + break; default: isIndicatorSectionValid = false; break; diff --git a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/hooks/use_unregister_fields.ts b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/hooks/use_unregister_fields.ts index d461c841940a4..11d0ed133f7de 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/hooks/use_unregister_fields.ts +++ b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/hooks/use_unregister_fields.ts @@ -20,6 +20,7 @@ import { HISTOGRAM_DEFAULT_VALUES, SLO_EDIT_FORM_DEFAULT_VALUES, TIMESLICE_METRIC_DEFAULT_VALUES, + SLO_EDIT_FORM_DEFAULT_VALUES_SYNTHETICS_AVAILABILITY, } from '../constants'; import { CreateSLOForm } from '../types'; @@ -109,6 +110,11 @@ export function useUnregisterFields({ isEditMode }: { isEditMode: boolean }) { } ); break; + case 'sli.synthetics.availability': + reset(Object.assign({}, SLO_EDIT_FORM_DEFAULT_VALUES_SYNTHETICS_AVAILABILITY), { + keepDefaultValues: true, + }); + break; default: assertNever(indicatorType); } diff --git a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/types.ts b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/types.ts index a976c46336f58..4a85822f84ea3 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/types.ts +++ b/x-pack/plugins/observability_solution/observability/public/pages/slo_edit/types.ts @@ -7,10 +7,10 @@ import { BudgetingMethod, Indicator, TimeWindow } from '@kbn/slo-schema'; -export interface CreateSLOForm { +export interface CreateSLOForm { name: string; description: string; - indicator: Indicator; + indicator: IndicatorType; timeWindow: { duration: string; type: TimeWindow; diff --git a/x-pack/plugins/observability_solution/observability/public/pages/slos/components/common/good_bad_events_chart.tsx b/x-pack/plugins/observability_solution/observability/public/pages/slos/components/common/good_bad_events_chart.tsx new file mode 100644 index 0000000000000..666a9a783f56f --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/public/pages/slos/components/common/good_bad_events_chart.tsx @@ -0,0 +1,171 @@ +/* + * 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, + Tooltip, + TooltipType, + 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 { useKibana } from '../../../../utils/kibana_react'; +import { openInDiscover } from '../../../../utils/slo/get_discover_link'; + +export interface Props { + data: GetPreviewDataResponse; + slo?: SLOWithSummaryResponse; + annotation?: React.ReactNode; + isLoading?: boolean; + bottomTitle?: string; +} + +export function GoodBadEventsChart({ + annotation, + bottomTitle, + data, + slo, + 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 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.observability.slo.sloDetails.eventsChartPanel.goodEventsLabel', + { defaultMessage: 'Good events' } + ); + + const badEventId = i18n.translate( + 'xpack.observability.slo.sloDetails.eventsChartPanel.badEventsLabel', + { defaultMessage: 'Bad events' } + ); + + const barClickHandler = (params: XYChartElementEvent[]) => { + if (slo?.indicator?.type === 'sli.kql.custom') { + const [datanum, eventDetail] = params[0]; + const isBad = eventDetail.specId === badEventId; + const timeRange = { + from: moment(datanum.x).toISOString(), + to: moment(datanum.x).add(intervalInMilliseconds, 'ms').toISOString(), + mode: 'absolute' as const, + }; + openInDiscover(discover, slo, isBad, !isBad, timeRange); + } + }; + + return ( + <> + {isLoading && } + + {!isLoading && ( + + + } + onPointerUpdate={handleCursorUpdate} + externalPointerEvents={{ + tooltip: { visible: true }, + }} + pointerUpdateDebounce={0} + pointerUpdateTrigger={'x'} + locale={i18n.getLocale()} + onElementClick={barClickHandler as ElementClickListener} + /> + {annotation} + moment(d).format(dateFormat)} + /> + numeral(d).format(yAxisNumberFormat)} + domain={domain} + /> + <> + ({ + key: new Date(datum.date).getTime(), + value: datum.events?.good, + })) ?? [] + } + /> + + ({ + key: new Date(datum.date).getTime(), + value: datum.events?.bad, + })) ?? [] + } + /> + + + )} + + ); +} diff --git a/x-pack/plugins/observability_solution/observability/public/utils/slo/get_discover_link.ts b/x-pack/plugins/observability_solution/observability/public/utils/slo/get_discover_link.ts index 160ebc887dfdb..cc21bd6944fa2 100644 --- a/x-pack/plugins/observability_solution/observability/public/utils/slo/get_discover_link.ts +++ b/x-pack/plugins/observability_solution/observability/public/utils/slo/get_discover_link.ts @@ -67,7 +67,8 @@ function createDiscoverLocator( const timeFieldName = slo.indicator.type !== 'sli.apm.transactionDuration' && - slo.indicator.type !== 'sli.apm.transactionErrorRate' + slo.indicator.type !== 'sli.apm.transactionErrorRate' && + slo.indicator.type !== 'sli.synthetics.availability' ? slo.indicator.params.timestampField : '@timestamp'; diff --git a/x-pack/plugins/observability_solution/observability/public/utils/slo/labels.ts b/x-pack/plugins/observability_solution/observability/public/utils/slo/labels.ts index 43ed455f5a9e3..f7dd0bd9fd728 100644 --- a/x-pack/plugins/observability_solution/observability/public/utils/slo/labels.ts +++ b/x-pack/plugins/observability_solution/observability/public/utils/slo/labels.ts @@ -42,6 +42,11 @@ export const INDICATOR_APM_AVAILABILITY = i18n.translate( { defaultMessage: 'APM availability' } ); +export const INDICATOR_SYNTHETICS_AVAILABILITY = i18n.translate( + 'xpack.observability.slo.indicators.syntheticsAvailability', + { defaultMessage: 'Synthetics availability' } +); + export function toIndicatorTypeLabel( indicatorType: SLOWithSummaryResponse['indicator']['type'] ): string { @@ -55,6 +60,9 @@ export function toIndicatorTypeLabel( case 'sli.apm.transactionErrorRate': return INDICATOR_APM_AVAILABILITY; + case 'sli.synthetics.availability': + return INDICATOR_SYNTHETICS_AVAILABILITY; + case 'sli.metric.custom': return INDICATOR_CUSTOM_METRIC; diff --git a/x-pack/plugins/observability_solution/observability/server/assets/component_templates/slo_mappings_template.ts b/x-pack/plugins/observability_solution/observability/server/assets/component_templates/slo_mappings_template.ts index dfb98fcb42206..175def562c63f 100644 --- a/x-pack/plugins/observability_solution/observability/server/assets/component_templates/slo_mappings_template.ts +++ b/x-pack/plugins/observability_solution/observability/server/assets/component_templates/slo_mappings_template.ts @@ -45,6 +45,7 @@ export const getSLOMappingsTemplate = (name: string): ClusterPutComponentTemplat }, }, }, + // SLO field mappings slo: { properties: { id: { diff --git a/x-pack/plugins/observability_solution/observability/server/assets/component_templates/slo_summary_mappings_template.ts b/x-pack/plugins/observability_solution/observability/server/assets/component_templates/slo_summary_mappings_template.ts index 15632ea033252..f16155792dc53 100644 --- a/x-pack/plugins/observability_solution/observability/server/assets/component_templates/slo_summary_mappings_template.ts +++ b/x-pack/plugins/observability_solution/observability/server/assets/component_templates/slo_summary_mappings_template.ts @@ -35,6 +35,7 @@ export const getSLOSummaryMappingsTemplate = ( }, }, }, + // SLO field mappings slo: { properties: { id: { diff --git a/x-pack/plugins/observability_solution/observability/server/domain/models/common.ts b/x-pack/plugins/observability_solution/observability/server/domain/models/common.ts index d4cf51878271a..9e920a7ed9074 100644 --- a/x-pack/plugins/observability_solution/observability/server/domain/models/common.ts +++ b/x-pack/plugins/observability_solution/observability/server/domain/models/common.ts @@ -13,6 +13,7 @@ import { summarySchema, groupingsSchema, groupSummarySchema, + metaSchema, } from '@kbn/slo-schema'; type Status = t.TypeOf; @@ -20,6 +21,7 @@ type DateRange = t.TypeOf; type HistoricalSummary = t.TypeOf; type Summary = t.TypeOf; type Groupings = t.TypeOf; +type Meta = t.TypeOf; type GroupSummary = t.TypeOf; -export type { DateRange, Groupings, HistoricalSummary, Status, Summary, GroupSummary }; +export type { DateRange, Groupings, GroupSummary, HistoricalSummary, Meta, Status, Summary }; diff --git a/x-pack/plugins/observability_solution/observability/server/domain/models/indicators.ts b/x-pack/plugins/observability_solution/observability/server/domain/models/indicators.ts index 100c04dfda35b..257994bce2f25 100644 --- a/x-pack/plugins/observability_solution/observability/server/domain/models/indicators.ts +++ b/x-pack/plugins/observability_solution/observability/server/domain/models/indicators.ts @@ -9,6 +9,7 @@ import * as t from 'io-ts'; import { apmTransactionDurationIndicatorSchema, apmTransactionErrorRateIndicatorSchema, + syntheticsAvailabilityIndicatorSchema, indicatorDataSchema, indicatorSchema, indicatorTypesSchema, @@ -18,6 +19,7 @@ import { type APMTransactionErrorRateIndicator = t.TypeOf; type APMTransactionDurationIndicator = t.TypeOf; +type SyntheticsAvailabilityIndicator = t.TypeOf; type KQLCustomIndicator = t.TypeOf; type MetricCustomIndicator = t.TypeOf; type Indicator = t.TypeOf; @@ -29,6 +31,7 @@ export type { IndicatorTypes, APMTransactionErrorRateIndicator, APMTransactionDurationIndicator, + SyntheticsAvailabilityIndicator, KQLCustomIndicator, MetricCustomIndicator, IndicatorData, diff --git a/x-pack/plugins/observability_solution/observability/server/routes/slo/route.ts b/x-pack/plugins/observability_solution/observability/server/routes/slo/route.ts index 2ec1f37e7ac27..1c8340617648b 100644 --- a/x-pack/plugins/observability_solution/observability/server/routes/slo/route.ts +++ b/x-pack/plugins/observability_solution/observability/server/routes/slo/route.ts @@ -51,6 +51,7 @@ import { DefaultSummaryTransformGenerator } from '../../services/slo/summary_tra import { ApmTransactionDurationTransformGenerator, ApmTransactionErrorRateTransformGenerator, + SyntheticsAvailabilityTransformGenerator, HistogramTransformGenerator, KQLCustomTransformGenerator, MetricCustomTransformGenerator, @@ -63,6 +64,7 @@ import { createObservabilityServerRoute } from '../create_observability_server_r const transformGenerators: Record = { 'sli.apm.transactionDuration': new ApmTransactionDurationTransformGenerator(), 'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(), + 'sli.synthetics.availability': new SyntheticsAvailabilityTransformGenerator(), 'sli.kql.custom': new KQLCustomTransformGenerator(), 'sli.metric.custom': new MetricCustomTransformGenerator(), 'sli.histogram.custom': new HistogramTransformGenerator(), @@ -94,7 +96,12 @@ const createSLORoute = createObservabilityServerRoute({ const esClient = (await context.core).elasticsearch.client.asCurrentUser; const soClient = (await context.core).savedObjects.client; const repository = new KibanaSavedObjectsSLORepository(soClient, logger); - const transformManager = new DefaultTransformManager(transformGenerators, esClient, logger); + const transformManager = new DefaultTransformManager( + transformGenerators, + esClient, + logger, + spaceId + ); const summaryTransformManager = new DefaultSummaryTransformManager( new DefaultSummaryTransformGenerator(), esClient, @@ -132,7 +139,12 @@ const inspectSLORoute = createObservabilityServerRoute({ const esClient = (await context.core).elasticsearch.client.asCurrentUser; const soClient = (await context.core).savedObjects.client; const repository = new KibanaSavedObjectsSLORepository(soClient, logger); - const transformManager = new DefaultTransformManager(transformGenerators, esClient, logger); + const transformManager = new DefaultTransformManager( + transformGenerators, + esClient, + logger, + spaceId + ); const summaryTransformManager = new DefaultSummaryTransformManager( new DefaultSummaryTransformGenerator(), esClient, @@ -168,7 +180,12 @@ const updateSLORoute = createObservabilityServerRoute({ const soClient = (await context.core).savedObjects.client; const repository = new KibanaSavedObjectsSLORepository(soClient, logger); - const transformManager = new DefaultTransformManager(transformGenerators, esClient, logger); + const transformManager = new DefaultTransformManager( + transformGenerators, + esClient, + logger, + spaceId + ); const summaryTransformManager = new DefaultSummaryTransformManager( new DefaultSummaryTransformGenerator(), esClient, @@ -197,21 +214,23 @@ const deleteSLORoute = createObservabilityServerRoute({ access: 'public', }, params: deleteSLOParamsSchema, - handler: async ({ - request, - context, - params, - logger, - dependencies: { getRulesClientWithRequest }, - }) => { + handler: async ({ request, context, params, logger, dependencies }) => { await assertPlatinumLicense(context); + const spaceId = + (await dependencies.spaces?.spacesService?.getActiveSpace(request))?.id ?? 'default'; + const esClient = (await context.core).elasticsearch.client.asCurrentUser; const soClient = (await context.core).savedObjects.client; - const rulesClient = getRulesClientWithRequest(request); + const rulesClient = dependencies.getRulesClientWithRequest(request); const repository = new KibanaSavedObjectsSLORepository(soClient, logger); - const transformManager = new DefaultTransformManager(transformGenerators, esClient, logger); + const transformManager = new DefaultTransformManager( + transformGenerators, + esClient, + logger, + spaceId + ); const summaryTransformManager = new DefaultSummaryTransformManager( new DefaultSummaryTransformGenerator(), @@ -260,14 +279,22 @@ const enableSLORoute = createObservabilityServerRoute({ access: 'public', }, params: manageSLOParamsSchema, - handler: async ({ context, params, logger }) => { + handler: async ({ request, context, params, logger, dependencies }) => { await assertPlatinumLicense(context); + const spaceId = + (await dependencies.spaces?.spacesService?.getActiveSpace(request))?.id ?? 'default'; + const soClient = (await context.core).savedObjects.client; const esClient = (await context.core).elasticsearch.client.asCurrentUser; const repository = new KibanaSavedObjectsSLORepository(soClient, logger); - const transformManager = new DefaultTransformManager(transformGenerators, esClient, logger); + const transformManager = new DefaultTransformManager( + transformGenerators, + esClient, + logger, + spaceId + ); const summaryTransformManager = new DefaultSummaryTransformManager( new DefaultSummaryTransformGenerator(), esClient, @@ -289,14 +316,22 @@ const disableSLORoute = createObservabilityServerRoute({ access: 'public', }, params: manageSLOParamsSchema, - handler: async ({ context, params, logger }) => { + handler: async ({ request, context, params, logger, dependencies }) => { await assertPlatinumLicense(context); + const spaceId = + (await dependencies.spaces?.spacesService?.getActiveSpace(request))?.id ?? 'default'; + const soClient = (await context.core).savedObjects.client; const esClient = (await context.core).elasticsearch.client.asCurrentUser; const repository = new KibanaSavedObjectsSLORepository(soClient, logger); - const transformManager = new DefaultTransformManager(transformGenerators, esClient, logger); + const transformManager = new DefaultTransformManager( + transformGenerators, + esClient, + logger, + spaceId + ); const summaryTransformManager = new DefaultSummaryTransformManager( new DefaultSummaryTransformGenerator(), esClient, @@ -327,7 +362,12 @@ const resetSLORoute = createObservabilityServerRoute({ const esClient = (await context.core).elasticsearch.client.asCurrentUser; const repository = new KibanaSavedObjectsSLORepository(soClient, logger); - const transformManager = new DefaultTransformManager(transformGenerators, esClient, logger); + const transformManager = new DefaultTransformManager( + transformGenerators, + esClient, + logger, + spaceId + ); const summaryTransformManager = new DefaultSummaryTransformManager( new DefaultSummaryTransformGenerator(), esClient, @@ -527,11 +567,14 @@ const getPreviewData = createObservabilityServerRoute({ access: 'internal', }, params: getPreviewDataParamsSchema, - handler: async ({ context, params }) => { + handler: async ({ request, context, params, dependencies }) => { await assertPlatinumLicense(context); + const spaceId = + (await dependencies.spaces?.spacesService?.getActiveSpace(request))?.id ?? 'default'; + const esClient = (await context.core).elasticsearch.client.asCurrentUser; - const service = new GetPreviewData(esClient); + const service = new GetPreviewData(esClient, spaceId); return await service.execute(params.body); }, }); diff --git a/x-pack/plugins/observability_solution/observability/server/services/slo/__snapshots__/summary_client.test.ts.snap b/x-pack/plugins/observability_solution/observability/server/services/slo/__snapshots__/summary_client.test.ts.snap index e49d405f1e1c0..303bec0c5bdb3 100644 --- a/x-pack/plugins/observability_solution/observability/server/services/slo/__snapshots__/summary_client.test.ts.snap +++ b/x-pack/plugins/observability_solution/observability/server/services/slo/__snapshots__/summary_client.test.ts.snap @@ -3,6 +3,7 @@ exports[`SummaryClient fetchSummary with calendar aligned and timeslices SLO returns the summary 1`] = ` Object { "groupings": Object {}, + "meta": Object {}, "summary": Object { "errorBudget": Object { "consumed": 0, @@ -19,6 +20,7 @@ Object { exports[`SummaryClient fetchSummary with rolling and occurrences SLO returns the summary 1`] = ` Object { "groupings": Object {}, + "meta": Object {}, "summary": Object { "errorBudget": Object { "consumed": 0, @@ -35,6 +37,7 @@ Object { exports[`SummaryClient fetchSummary with rolling and timeslices SLO returns the summary 1`] = ` Object { "groupings": Object {}, + "meta": Object {}, "summary": Object { "errorBudget": Object { "consumed": 0, diff --git a/x-pack/plugins/observability_solution/observability/server/services/slo/fixtures/slo.ts b/x-pack/plugins/observability_solution/observability/server/services/slo/fixtures/slo.ts index 0f75c83775489..cf4e7ae8fba34 100644 --- a/x-pack/plugins/observability_solution/observability/server/services/slo/fixtures/slo.ts +++ b/x-pack/plugins/observability_solution/observability/server/services/slo/fixtures/slo.ts @@ -11,11 +11,12 @@ import { CreateSLOParams, HistogramIndicator, sloSchema, + SyntheticsAvailabilityIndicator, TimesliceMetricIndicator, } from '@kbn/slo-schema'; import { cloneDeep } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; -import { SLO_MODEL_VERSION } from '../../../../common/slo/constants'; +import { SLO_MODEL_VERSION, SYNTHETICS_INDEX_PATTERN } from '../../../../common/slo/constants'; import { APMTransactionDurationIndicator, APMTransactionErrorRateIndicator, @@ -60,6 +61,19 @@ export const createAPMTransactionDurationIndicator = ( }, }); +export const createSyntheticsAvailabilityIndicator = ( + params: Partial = {} +): Indicator => ({ + type: 'sli.synthetics.availability', + params: { + index: SYNTHETICS_INDEX_PATTERN, + tags: [], + projects: [], + monitorIds: [], + ...params, + }, +}); + export const createKQLCustomIndicator = ( params: Partial = {} ): Indicator => ({ diff --git a/x-pack/plugins/observability_solution/observability/server/services/slo/get_preview_data.ts b/x-pack/plugins/observability_solution/observability/server/services/slo/get_preview_data.ts index c3265654dafcd..6686601ee66e0 100644 --- a/x-pack/plugins/observability_solution/observability/server/services/slo/get_preview_data.ts +++ b/x-pack/plugins/observability_solution/observability/server/services/slo/get_preview_data.ts @@ -9,6 +9,7 @@ import { calculateAuto } from '@kbn/calculate-auto'; import { ALL_VALUE, APMTransactionErrorRateIndicator, + SyntheticsAvailabilityIndicator, GetPreviewDataParams, GetPreviewDataResponse, HistogramIndicator, @@ -21,6 +22,7 @@ import moment from 'moment'; import { ElasticsearchClient } from '@kbn/core/server'; import { estypes } from '@elastic/elasticsearch'; import { getElasticsearchQueryOrThrow } from './transform_generators'; +import { buildParamValues } from './transform_generators/synthetics_availability'; import { typedSearch } from '../../utils/queries'; import { APMTransactionDurationIndicator } from '../../domain/models'; import { computeSLI } from '../../domain/services'; @@ -29,6 +31,7 @@ import { GetHistogramIndicatorAggregation, GetTimesliceMetricIndicatorAggregation, } from './aggregations'; +import { SYNTHETICS_INDEX_PATTERN } from '../../../common/slo/constants'; interface Options { range: { @@ -41,7 +44,7 @@ interface Options { groupings?: Record; } export class GetPreviewData { - constructor(private esClient: ElasticsearchClient) {} + constructor(private esClient: ElasticsearchClient, private spaceId: string) {} private async getAPMTransactionDurationPreviewData( indicator: APMTransactionDurationIndicator, @@ -461,6 +464,94 @@ export class GetPreviewData { } } + private async getSyntheticsAvailabilityPreviewData( + indicator: SyntheticsAvailabilityIndicator, + options: Options + ): Promise { + const filter = []; + const { monitorIds, tags, projects } = buildParamValues({ + monitorIds: indicator.params.monitorIds || [], + tags: indicator.params.tags || [], + projects: indicator.params.projects || [], + }); + if (!monitorIds.includes(ALL_VALUE) && monitorIds.length > 0) + filter.push({ + terms: { 'monitor.id': monitorIds }, + }); + if (!tags.includes(ALL_VALUE) && tags.length > 0) + filter.push({ + terms: { tags }, + }); + if (!projects.includes(ALL_VALUE) && projects.length > 0) + filter.push({ + terms: { 'monitor.project.id': projects }, + }); + + const result = await this.esClient.search({ + index: SYNTHETICS_INDEX_PATTERN, + size: 0, + query: { + bool: { + filter: [ + { range: { '@timestamp': { gte: options.range.start, lte: options.range.end } } }, + { term: { 'summary.final_attempt': true } }, + { term: { 'meta.space_id': this.spaceId } }, + ...filter, + ], + }, + }, + aggs: { + perMinute: { + date_histogram: { + field: '@timestamp', + fixed_interval: '10m', + }, + aggs: { + good: { + filter: { + term: { + 'monitor.status': 'up', + }, + }, + }, + bad: { + filter: { + term: { + 'monitor.status': 'down', + }, + }, + }, + total: { + filter: { + match_all: {}, + }, + }, + }, + }, + }, + }); + + const data: GetPreviewDataResponse = []; + + // @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: computeSLI(good, total), + events: { + good, + bad, + total, + }, + }); + }); + + return data; + } + public async execute(params: GetPreviewDataParams): Promise { try { // If the time range is 24h or less, then we want to use a 1m bucket for the @@ -492,6 +583,8 @@ export class GetPreviewData { return this.getAPMTransactionDurationPreviewData(params.indicator, options); case 'sli.apm.transactionErrorRate': return this.getAPMTransactionErrorPreviewData(params.indicator, options); + case 'sli.synthetics.availability': + return this.getSyntheticsAvailabilityPreviewData(params.indicator, options); case 'sli.kql.custom': return this.getCustomKQLPreviewData(params.indicator, options); case 'sli.histogram.custom': diff --git a/x-pack/plugins/observability_solution/observability/server/services/slo/get_slo.test.ts b/x-pack/plugins/observability_solution/observability/server/services/slo/get_slo.test.ts index 984816dd57962..2143c5f8d8e50 100644 --- a/x-pack/plugins/observability_solution/observability/server/services/slo/get_slo.test.ts +++ b/x-pack/plugins/observability_solution/observability/server/services/slo/get_slo.test.ts @@ -30,6 +30,7 @@ describe('GetSLO', () => { mockRepository.findById.mockResolvedValueOnce(slo); mockSummaryClient.computeSummary.mockResolvedValueOnce({ groupings: {}, + meta: {}, summary: { status: 'HEALTHY', sliValue: 0.9999, @@ -89,6 +90,7 @@ describe('GetSLO', () => { groupBy: slo.groupBy, groupings: {}, instanceId: ALL_VALUE, + meta: {}, version: SLO_MODEL_VERSION, }); }); diff --git a/x-pack/plugins/observability_solution/observability/server/services/slo/get_slo.ts b/x-pack/plugins/observability_solution/observability/server/services/slo/get_slo.ts index ec0a1b6711e79..c9754356e2ab7 100644 --- a/x-pack/plugins/observability_solution/observability/server/services/slo/get_slo.ts +++ b/x-pack/plugins/observability_solution/observability/server/services/slo/get_slo.ts @@ -5,7 +5,7 @@ * 2.0. */ import { ALL_VALUE, GetSLOParams, GetSLOResponse, getSLOResponseSchema } from '@kbn/slo-schema'; -import { Groupings, SLO, Summary } from '../../domain/models'; +import { Groupings, Meta, SLO, Summary } from '../../domain/models'; import { SLORepository } from './slo_repository'; import { SummaryClient } from './summary_client'; @@ -16,12 +16,20 @@ export class GetSLO { const slo = await this.repository.findById(sloId); const instanceId = params.instanceId ?? ALL_VALUE; - const { summary, groupings } = await this.summaryClient.computeSummary(slo, instanceId); + const { summary, groupings, meta } = await this.summaryClient.computeSummary(slo, instanceId); - return getSLOResponseSchema.encode(mergeSloWithSummary(slo, summary, instanceId, groupings)); + return getSLOResponseSchema.encode( + mergeSloWithSummary(slo, summary, instanceId, groupings, meta) + ); } } -function mergeSloWithSummary(slo: SLO, summary: Summary, instanceId: string, groupings: Groupings) { - return { ...slo, instanceId, summary, groupings }; +function mergeSloWithSummary( + slo: SLO, + summary: Summary, + instanceId: string, + groupings: Groupings, + meta: Meta +) { + return { ...slo, instanceId, summary, groupings, meta }; } diff --git a/x-pack/plugins/observability_solution/observability/server/services/slo/summary_client.ts b/x-pack/plugins/observability_solution/observability/server/services/slo/summary_client.ts index 1f9656bf70d96..7fdf0e63c9ee5 100644 --- a/x-pack/plugins/observability_solution/observability/server/services/slo/summary_client.ts +++ b/x-pack/plugins/observability_solution/observability/server/services/slo/summary_client.ts @@ -16,7 +16,7 @@ import { } from '@kbn/slo-schema'; import moment from 'moment'; import { SLO_DESTINATION_INDEX_PATTERN } from '../../../common/slo/constants'; -import { DateRange, SLO, Summary, Groupings } from '../../domain/models'; +import { DateRange, SLO, Summary, Groupings, Meta } from '../../domain/models'; import { computeSLI, computeSummaryStatus, toErrorBudget } from '../../domain/services'; import { toDateRange } from '../../domain/services/date_range'; import { getFlattenedGroupings } from './utils'; @@ -26,7 +26,7 @@ export interface SummaryClient { slo: SLO, groupings?: string, instanceId?: string - ): Promise<{ summary: Summary; groupings: Groupings }>; + ): Promise<{ summary: Summary; groupings: Groupings; meta: Meta }>; } export class DefaultSummaryClient implements SummaryClient { @@ -35,7 +35,7 @@ export class DefaultSummaryClient implements SummaryClient { async computeSummary( slo: SLO, instanceId: string = ALL_VALUE - ): Promise<{ summary: Summary; groupings: Groupings }> { + ): Promise<{ summary: Summary; groupings: Groupings; meta: Meta }> { const dateRange = toDateRange(slo.timeWindow); const isDefinedWithGroupBy = ![slo.groupBy].flat().includes(ALL_VALUE); const hasInstanceId = instanceId !== ALL_VALUE; @@ -54,7 +54,7 @@ export class DefaultSummaryClient implements SummaryClient { }, ], _source: { - includes: ['slo.groupings'], + includes: ['slo.groupings', 'monitor', 'observer', 'config_id'], }, size: 1, }, @@ -101,7 +101,8 @@ export class DefaultSummaryClient implements SummaryClient { // @ts-ignore value is not type correctly const total = result.aggregations?.total?.value ?? 0; // @ts-expect-error AggregationsAggregationContainer needs to be updated with top_hits - const groupings = result.aggregations?.last_doc?.hits?.hits?.[0]?._source?.slo?.groupings; + const source = result.aggregations?.last_doc?.hits?.hits?.[0]?._source; + const groupings = source?.slo?.groupings; const sliValue = computeSLI(good, total); const initialErrorBudget = 1 - slo.objective.target; @@ -135,6 +136,7 @@ export class DefaultSummaryClient implements SummaryClient { status: computeSummaryStatus(slo, sliValue, errorBudget), }, groupings: groupings ? getFlattenedGroupings({ groupBy: slo.groupBy, groupings }) : {}, + meta: getMetaFields(slo, source || {}), }; } } @@ -146,3 +148,24 @@ function computeTotalSlicesFromDateRange(dateRange: DateRange, timesliceWindow: ); return Math.ceil(dateRangeDurationInUnit / timesliceWindow!.value); } + +export function getMetaFields( + slo: SLO, + source: { monitor?: { id?: string }; config_id?: string; observer?: { name?: string } } +): Meta { + const { + indicator: { type }, + } = slo; + switch (type) { + case 'sli.synthetics.availability': + return { + synthetics: { + monitorId: source.monitor?.id || '', + locationId: source.observer?.name || '', + configId: source.config_id || '', + }, + }; + default: + return {}; + } +} diff --git a/x-pack/plugins/observability_solution/observability/server/services/slo/summary_search_client.ts b/x-pack/plugins/observability_solution/observability/server/services/slo/summary_search_client.ts index f8f17f2afe2f6..454d40c2f0cbc 100644 --- a/x-pack/plugins/observability_solution/observability/server/services/slo/summary_search_client.ts +++ b/x-pack/plugins/observability_solution/observability/server/services/slo/summary_search_client.ts @@ -136,6 +136,7 @@ export class DefaultSummarySearchClient implements SummarySearchClient { .slice(0, pagination.perPage); const finalTotal = total - (tempSummaryDocuments.length - tempSummaryDocumentsDeduped.length); + return { total: finalTotal, perPage: pagination.perPage, diff --git a/x-pack/plugins/observability_solution/observability/server/services/slo/tasks/orphan_summary_cleanup_task.test.ts b/x-pack/plugins/observability_solution/observability/server/services/slo/tasks/orphan_summary_cleanup_task.test.ts index 6638ab84c2c0a..2821560e6cabc 100644 --- a/x-pack/plugins/observability_solution/observability/server/services/slo/tasks/orphan_summary_cleanup_task.test.ts +++ b/x-pack/plugins/observability_solution/observability/server/services/slo/tasks/orphan_summary_cleanup_task.test.ts @@ -12,6 +12,7 @@ import { loggerMock } from '@kbn/logging-mocks'; import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; import { times } from 'lodash'; import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; +import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from '../../../../common/slo/constants'; const taskManagerSetup = taskManagerMock.createSetup(); const taskManagerStart = taskManagerMock.createStart(); @@ -59,7 +60,7 @@ describe('SloSummaryCleanupTask', () => { expect(task.fetchSloSummariesIds).toHaveBeenCalled(); expect(esClient.deleteByQuery).toHaveBeenCalledWith({ - index: '.slo-observability.summary-v3*', + index: SLO_SUMMARY_DESTINATION_INDEX_PATTERN, query: { bool: { should: [ @@ -100,7 +101,7 @@ describe('SloSummaryCleanupTask', () => { expect(esClient.deleteByQuery).toHaveBeenCalledTimes(1); expect(esClient.deleteByQuery).toHaveBeenNthCalledWith(1, { wait_for_completion: false, - index: '.slo-observability.summary-v3*', + index: SLO_SUMMARY_DESTINATION_INDEX_PATTERN, query: { bool: { should: getDeleteQueryFilter([ @@ -158,7 +159,7 @@ describe('SloSummaryCleanupTask', () => { expect(esClient.deleteByQuery).toHaveBeenCalledTimes(2); expect(esClient.deleteByQuery).toHaveBeenNthCalledWith(1, { - index: '.slo-observability.summary-v3*', + index: SLO_SUMMARY_DESTINATION_INDEX_PATTERN, query: { bool: { should: getDeleteQueryFilter([ @@ -174,7 +175,7 @@ describe('SloSummaryCleanupTask', () => { expect(esClient.deleteByQuery).toHaveBeenLastCalledWith({ wait_for_completion: false, - index: '.slo-observability.summary-v3*', + index: SLO_SUMMARY_DESTINATION_INDEX_PATTERN, query: { bool: { should: getDeleteQueryFilter([ @@ -232,7 +233,7 @@ describe('SloSummaryCleanupTask', () => { expect(esClient.deleteByQuery).toHaveBeenCalledTimes(2); expect(esClient.deleteByQuery).toHaveBeenNthCalledWith(1, { wait_for_completion: false, - index: '.slo-observability.summary-v3*', + index: SLO_SUMMARY_DESTINATION_INDEX_PATTERN, query: { bool: { should: getDeleteQueryFilter([ @@ -246,7 +247,7 @@ describe('SloSummaryCleanupTask', () => { }); expect(esClient.deleteByQuery).toHaveBeenLastCalledWith({ wait_for_completion: false, - index: '.slo-observability.summary-v3*', + index: SLO_SUMMARY_DESTINATION_INDEX_PATTERN, query: { bool: { should: getDeleteQueryFilter([ @@ -300,7 +301,7 @@ describe('SloSummaryCleanupTask', () => { expect(esClient.deleteByQuery).toHaveBeenNthCalledWith(1, { wait_for_completion: false, - index: '.slo-observability.summary-v3*', + index: SLO_SUMMARY_DESTINATION_INDEX_PATTERN, query: { bool: { should: getDeleteQueryFilter([ diff --git a/x-pack/plugins/observability_solution/observability/server/services/slo/transform_generators/index.ts b/x-pack/plugins/observability_solution/observability/server/services/slo/transform_generators/index.ts index 8bfaf865f340c..c58de27e9b98e 100644 --- a/x-pack/plugins/observability_solution/observability/server/services/slo/transform_generators/index.ts +++ b/x-pack/plugins/observability_solution/observability/server/services/slo/transform_generators/index.ts @@ -8,6 +8,7 @@ export * from './transform_generator'; export * from './apm_transaction_error_rate'; export * from './apm_transaction_duration'; +export * from './synthetics_availability'; export * from './kql_custom'; export * from './metric_custom'; export * from './histogram'; diff --git a/x-pack/plugins/observability_solution/observability/server/services/slo/transform_generators/synthetics_availability.test.ts b/x-pack/plugins/observability_solution/observability/server/services/slo/transform_generators/synthetics_availability.test.ts new file mode 100644 index 0000000000000..8db7f950865d5 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/server/services/slo/transform_generators/synthetics_availability.test.ts @@ -0,0 +1,403 @@ +/* + * 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 { ALL_VALUE } from '@kbn/slo-schema'; +import { SLO } from '../../../domain/models'; +import { createSLO, createSyntheticsAvailabilityIndicator } from '../fixtures/slo'; +import { SyntheticsAvailabilityTransformGenerator } from './synthetics_availability'; +import { SYNTHETICS_INDEX_PATTERN } from '../../../../common/slo/constants'; + +const generator = new SyntheticsAvailabilityTransformGenerator(); + +describe('Synthetics Availability Transform Generator', () => { + const spaceId = 'custom-space'; + + it('returns the expected transform params', () => { + const slo = createSLO({ id: 'irrelevant', indicator: createSyntheticsAvailabilityIndicator() }); + const transform = generator.getTransformParams(slo, spaceId); + + expect(transform).toEqual({ + _meta: { + managed: true, + managed_by: 'observability', + version: 3, + }, + defer_validation: true, + description: 'Rolled-up SLI data for SLO: irrelevant [id: irrelevant, revision: 1]', + dest: { + index: '.slo-observability.sli-v3', + pipeline: '.slo-observability.sli.pipeline-v3', + }, + frequency: '1m', + pivot: { + aggregations: { + 'slo.denominator': { + filter: { + term: { + 'summary.final_attempt': true, + }, + }, + }, + 'slo.numerator': { + filter: { + term: { + 'monitor.status': 'up', + }, + }, + }, + }, + group_by: { + '@timestamp': { + date_histogram: { + field: '@timestamp', + fixed_interval: '1m', + }, + }, + config_id: { + terms: { + field: 'config_id', + }, + }, + 'observer.name': { + terms: { + field: 'observer.name', + }, + }, + 'slo.groupings.monitor.name': { + terms: { + field: 'monitor.name', + }, + }, + 'slo.groupings.observer.geo.name': { + terms: { + field: 'observer.geo.name', + }, + }, + 'slo.id': { + terms: { + field: 'slo.id', + }, + }, + 'slo.instanceId': { + terms: { + field: 'slo.instanceId', + }, + }, + 'slo.revision': { + terms: { + field: 'slo.revision', + }, + }, + }, + }, + settings: { + deduce_mappings: false, + unattended: true, + }, + source: { + index: SYNTHETICS_INDEX_PATTERN, + query: { + bool: { + filter: [ + { + term: { + 'summary.final_attempt': true, + }, + }, + { + term: { + 'meta.space_id': 'custom-space', + }, + }, + { + range: { + '@timestamp': { + gte: 'now-7d/d', + }, + }, + }, + ], + }, + }, + runtime_mappings: { + 'slo.id': { + script: { + source: "emit('irrelevant')", + }, + type: 'keyword', + }, + 'slo.instanceId': { + script: { + source: "emit('*')", + }, + type: 'keyword', + }, + 'slo.revision': { + script: { + source: 'emit(1)', + }, + type: 'long', + }, + }, + }, + sync: { + time: { + delay: '1m', + field: 'event.ingested', + }, + }, + transform_id: 'slo-irrelevant-1', + }); + expect(transform.source.query?.bool?.filter).toContainEqual({ + term: { + 'summary.final_attempt': true, + }, + }); + }); + + it('groups by config id and observer.name when using default groupings', () => { + const slo = createSLO({ + id: 'irrelevant', + indicator: createSyntheticsAvailabilityIndicator(), + }); + const transform = generator.getTransformParams(slo, spaceId); + + expect(transform.pivot?.group_by).toEqual( + expect.objectContaining({ + config_id: { + terms: { + field: 'config_id', + }, + }, + 'observer.name': { + terms: { + field: 'observer.name', + }, + }, + }) + ); + }); + + it('does not include config id and observer.name when using non default groupings', () => { + const slo = createSLO({ + id: 'irrelevant', + indicator: createSyntheticsAvailabilityIndicator(), + groupBy: ['host.name'], + }); + const transform = generator.getTransformParams(slo, spaceId); + + expect(transform.pivot?.group_by).not.toEqual( + expect.objectContaining({ + config_id: { + terms: { + field: 'config_id', + }, + }, + 'observer.name': { + terms: { + field: 'observer.name', + }, + }, + }) + ); + + expect(transform.pivot?.group_by).toEqual( + expect.objectContaining({ + 'slo.groupings.host.name': { + terms: { + field: 'host.name', + }, + }, + }) + ); + }); + + it.each([[[]], [[ALL_VALUE]]])( + 'adds observer.geo.name and monitor.name to groupings key by default, multi group by', + (groupBy) => { + const slo = createSLO({ + id: 'irrelevant', + indicator: createSyntheticsAvailabilityIndicator(), + groupBy, + }); + const transform = generator.getTransformParams(slo, spaceId); + + expect(transform.pivot?.group_by).toEqual( + expect.objectContaining({ + 'slo.groupings.monitor.name': { + terms: { + field: 'monitor.name', + }, + }, + 'slo.groupings.observer.geo.name': { + terms: { + field: 'observer.geo.name', + }, + }, + }) + ); + } + ); + + it.each([[''], [ALL_VALUE]])( + 'adds observer.geo.name and monitor.name to groupings key by default, single group by', + (groupBy) => { + const slo = createSLO({ + id: 'irrelevant', + indicator: createSyntheticsAvailabilityIndicator(), + groupBy, + }); + const transform = generator.getTransformParams(slo, spaceId); + + expect(transform.pivot?.group_by).toEqual( + expect.objectContaining({ + 'slo.groupings.monitor.name': { + terms: { + field: 'monitor.name', + }, + }, + 'slo.groupings.observer.geo.name': { + terms: { + field: 'observer.geo.name', + }, + }, + }) + ); + } + ); + + it.each([['host.name'], [['host.name']]])('handles custom groupBy', (groupBy) => { + const slo = createSLO({ + id: 'irrelevant', + indicator: createSyntheticsAvailabilityIndicator(), + groupBy, + }); + const transform = generator.getTransformParams(slo, spaceId); + + expect(transform.pivot?.group_by).toEqual( + expect.objectContaining({ + 'slo.groupings.host.name': { + terms: { + field: 'host.name', + }, + }, + }) + ); + }); + + it('filters by summary.final_attempt', () => { + const slo = createSLO({ id: 'irrelevant', indicator: createSyntheticsAvailabilityIndicator() }); + const transform = generator.getTransformParams(slo, spaceId); + + expect(transform.source.query?.bool?.filter).toContainEqual({ + term: { + 'summary.final_attempt': true, + }, + }); + }); + + it('adds tag filters', () => { + const tags = [ + { value: 'tag-1', label: 'tag1' }, + { value: 'tag-2', label: 'tag2' }, + ]; + const indicator = createSyntheticsAvailabilityIndicator(); + const slo = createSLO({ + id: 'irrelevant', + indicator: { + ...indicator, + params: { + ...indicator.params, + tags, + }, + } as SLO['indicator'], + }); + const transform = generator.getTransformParams(slo, spaceId); + + expect(transform.source.query?.bool?.filter).toContainEqual({ + terms: { + tags: ['tag-1', 'tag-2'], + }, + }); + expect(transform.pivot?.group_by?.tags).toEqual({ + terms: { + field: 'tags', + }, + }); + }); + + it('adds monitorId filter', () => { + const monitorIds = [ + { value: 'id-1', label: 'Monitor name 1' }, + { value: 'id-2', label: 'Monitor name 2' }, + ]; + const indicator = createSyntheticsAvailabilityIndicator(); + const slo = createSLO({ + id: 'irrelevant', + indicator: { + ...indicator, + params: { + ...indicator.params, + monitorIds, + }, + } as SLO['indicator'], + }); + const transform = generator.getTransformParams(slo, spaceId); + + expect(transform.source.query?.bool?.filter).toContainEqual({ + terms: { + 'monitor.id': ['id-1', 'id-2'], + }, + }); + expect(transform.pivot?.group_by?.['monitor.id']).toEqual({ + terms: { + field: 'monitor.id', + }, + }); + }); + + it('adds project id filter', () => { + const projects = [ + { value: 'id-1', label: 'Project name 1' }, + { value: 'id-2', label: 'Project name 2' }, + ]; + const indicator = createSyntheticsAvailabilityIndicator(); + const slo = createSLO({ + id: 'irrelevant', + indicator: { + ...indicator, + params: { + ...indicator.params, + projects, + }, + } as SLO['indicator'], + }); + const transform = generator.getTransformParams(slo, spaceId); + + expect(transform.source.query?.bool?.filter).toContainEqual({ + terms: { + 'monitor.project.id': ['id-1', 'id-2'], + }, + }); + expect(transform.pivot?.group_by?.['monitor.project.id']).toEqual({ + terms: { + field: 'monitor.project.id', + }, + }); + }); + + it('filters by space', () => { + const slo = createSLO({ id: 'irrelevant', indicator: createSyntheticsAvailabilityIndicator() }); + const transform = generator.getTransformParams(slo, spaceId); + + expect(transform.source.query?.bool?.filter).toContainEqual({ + term: { + 'meta.space_id': spaceId, + }, + }); + }); +}); diff --git a/x-pack/plugins/observability_solution/observability/server/services/slo/transform_generators/synthetics_availability.ts b/x-pack/plugins/observability_solution/observability/server/services/slo/transform_generators/synthetics_availability.ts new file mode 100644 index 0000000000000..78dd7f8e1a873 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/server/services/slo/transform_generators/synthetics_availability.ts @@ -0,0 +1,199 @@ +/* + * 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 { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types'; +import { estypes } from '@elastic/elasticsearch'; +import { + ALL_VALUE, + syntheticsAvailabilityIndicatorSchema, + occurrencesBudgetingMethodSchema, +} from '@kbn/slo-schema'; +import { getElasticsearchQueryOrThrow, TransformGenerator } from '.'; +import { + getSLOTransformId, + SLO_DESTINATION_INDEX_NAME, + SLO_INGEST_PIPELINE_NAME, + SYNTHETICS_INDEX_PATTERN, +} from '../../../../common/slo/constants'; +import { getSLOTransformTemplate } from '../../../assets/transform_templates/slo_transform_template'; +import { SyntheticsAvailabilityIndicator, SLO } from '../../../domain/models'; +import { InvalidTransformError } from '../../../errors'; +export class SyntheticsAvailabilityTransformGenerator extends TransformGenerator { + public getTransformParams(slo: SLO, spaceId: string): TransformPutTransformRequest { + if (!syntheticsAvailabilityIndicatorSchema.is(slo.indicator)) { + throw new InvalidTransformError(`Cannot handle SLO of indicator type: ${slo.indicator.type}`); + } + + return getSLOTransformTemplate( + this.buildTransformId(slo), + this.buildDescription(slo), + this.buildSource(slo, slo.indicator, spaceId), + this.buildDestination(), + this.buildGroupBy(slo, slo.indicator), + this.buildAggregations(slo), + this.buildSettings(slo, 'event.ingested') + ); + } + + private buildTransformId(slo: SLO): string { + return getSLOTransformId(slo.id, slo.revision); + } + + private buildGroupBy(slo: SLO, indicator: SyntheticsAvailabilityIndicator) { + // These are the group by fields that will be used in `groupings` key + // in the summary and rollup documents. For Synthetics, we want to use the + // user-readible `monitor.name` and `observer.geo.name` fields by default, + // unless otherwise specified by the user. + const flattenedGroupBy = [slo.groupBy].flat().filter((value) => !!value); + const groupings = + flattenedGroupBy.length && !flattenedGroupBy.includes(ALL_VALUE) + ? slo.groupBy + : ['monitor.name', 'observer.geo.name']; + + const hasTags = + !indicator.params.tags?.find((param) => param.value === ALL_VALUE) && + indicator.params.tags?.length; + const hasProjects = + !indicator.params.projects?.find((param) => param.value === ALL_VALUE) && + indicator.params.projects?.length; + const hasMonitorIds = + !indicator.params.monitorIds?.find((param) => param.value === ALL_VALUE) && + indicator.params.monitorIds?.length; + const includesDefaultGroupings = + groupings.includes('monitor.name') && groupings.includes('observer.geo.name'); + // These groupBy fields must match the fields from the source query, otherwise + // the transform will create permutations for each value present in the source. + // E.g. if environment is not specified in the source query, but we include it in the groupBy, + // we'll output documents for each environment value + const extraGroupByFields = { + /* additional fields needed to hyperlink back to the Synthetics app when + * grouping by monitor.name and observer.geo.name. + * `monitor.name` and `observer.geo.name` are the labels, while + * observer.name and config_id are the values. We need the values + * to build a URL back to Synthetics */ + ...(includesDefaultGroupings && { + 'observer.name': { terms: { field: 'observer.name' } }, + config_id: { terms: { field: 'config_id' } }, + }), + ...(hasMonitorIds && { 'monitor.id': { terms: { field: 'monitor.id' } } }), + ...(hasTags && { + tags: { terms: { field: 'tags' } }, + }), + ...(hasProjects && { + 'monitor.project.id': { terms: { field: 'monitor.project.id' } }, + }), + }; + + return this.buildCommonGroupBy( + { ...slo, groupBy: groupings }, + '@timestamp', + extraGroupByFields + ); + } + + private buildSource(slo: SLO, indicator: SyntheticsAvailabilityIndicator, spaceId: string) { + const queryFilter: estypes.QueryDslQueryContainer[] = [ + { term: { 'summary.final_attempt': true } }, + { term: { 'meta.space_id': spaceId } }, + { + range: { + '@timestamp': { + gte: `now-${slo.timeWindow.duration.format()}/d`, + }, + }, + }, + ]; + const { monitorIds, tags, projects } = buildParamValues({ + monitorIds: indicator.params.monitorIds || [], + tags: indicator.params.tags || [], + projects: indicator.params.projects || [], + }); + + if (!monitorIds.includes(ALL_VALUE) && monitorIds.length) { + queryFilter.push({ + terms: { + 'monitor.id': monitorIds, + }, + }); + } + + if (!tags.includes(ALL_VALUE) && tags.length) { + queryFilter.push({ + terms: { + tags, + }, + }); + } + + if (!projects.includes(ALL_VALUE) && projects.length) { + queryFilter.push({ + terms: { + 'monitor.project.id': projects, + }, + }); + } + + if (!!indicator.params.filter) { + queryFilter.push(getElasticsearchQueryOrThrow(indicator.params.filter)); + } + + return { + index: SYNTHETICS_INDEX_PATTERN, + runtime_mappings: { + ...this.buildCommonRuntimeMappings(slo), + }, + query: { + bool: { + filter: queryFilter, + }, + }, + }; + } + + private buildDestination() { + return { + pipeline: SLO_INGEST_PIPELINE_NAME, + index: SLO_DESTINATION_INDEX_NAME, + }; + } + + private buildAggregations(slo: SLO) { + if (!occurrencesBudgetingMethodSchema.is(slo.budgetingMethod)) { + throw new Error( + 'The sli.synthetics.availability indicator MUST have an occurances budgeting method.' + ); + } + + return { + 'slo.numerator': { + filter: { + term: { + 'monitor.status': 'up', + }, + }, + }, + 'slo.denominator': { + filter: { + term: { + 'summary.final_attempt': true, + }, + }, + }, + }; + } +} + +export const buildParamValues = ( + params: Record> +): Record => { + return Object.keys(params).reduce((acc, key) => { + return { + ...acc, + [key]: params[key]?.map((p) => p.value), + }; + }, {}); +}; diff --git a/x-pack/plugins/observability_solution/observability/server/services/slo/transform_generators/transform_generator.ts b/x-pack/plugins/observability_solution/observability/server/services/slo/transform_generators/transform_generator.ts index 24cd171a74d85..c630136a3b42e 100644 --- a/x-pack/plugins/observability_solution/observability/server/services/slo/transform_generators/transform_generator.ts +++ b/x-pack/plugins/observability_solution/observability/server/services/slo/transform_generators/transform_generator.ts @@ -14,7 +14,7 @@ import { TransformSettings } from '../../../assets/transform_templates/slo_trans import { SLO } from '../../../domain/models'; export abstract class TransformGenerator { - public abstract getTransformParams(slo: SLO): TransformPutTransformRequest; + public abstract getTransformParams(slo: SLO, spaceId: string): TransformPutTransformRequest; public buildCommonRuntimeMappings(slo: SLO): MappingRuntimeFields { const groupings = [slo.groupBy].flat().filter((value) => !!value); diff --git a/x-pack/plugins/observability_solution/observability/server/services/slo/transform_manager.test.ts b/x-pack/plugins/observability_solution/observability/server/services/slo/transform_manager.test.ts index b05feb8d9e8f4..230292ad6e083 100644 --- a/x-pack/plugins/observability_solution/observability/server/services/slo/transform_manager.test.ts +++ b/x-pack/plugins/observability_solution/observability/server/services/slo/transform_manager.test.ts @@ -30,6 +30,7 @@ import { describe('TransformManager', () => { let esClientMock: ElasticsearchClientMock; let loggerMock: jest.Mocked; + const spaceId = 'default'; beforeEach(() => { esClientMock = elasticsearchServiceMock.createElasticsearchClient(); @@ -43,7 +44,7 @@ describe('TransformManager', () => { const generators: Record = { 'sli.apm.transactionDuration': new DummyTransformGenerator(), }; - const service = new DefaultTransformManager(generators, esClientMock, loggerMock); + const service = new DefaultTransformManager(generators, esClientMock, loggerMock, spaceId); await expect( service.install(createSLO({ indicator: createAPMTransactionErrorRateIndicator() })) @@ -55,7 +56,12 @@ describe('TransformManager', () => { const generators: Record = { 'sli.apm.transactionDuration': new FailTransformGenerator(), }; - const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock); + const transformManager = new DefaultTransformManager( + generators, + esClientMock, + loggerMock, + spaceId + ); await expect( transformManager.install( @@ -70,7 +76,12 @@ describe('TransformManager', () => { const generators: Record = { 'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(), }; - const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock); + const transformManager = new DefaultTransformManager( + generators, + esClientMock, + loggerMock, + spaceId + ); const slo = createSLO({ indicator: createAPMTransactionErrorRateIndicator() }); const transformId = await transformManager.install(slo); @@ -86,7 +97,12 @@ describe('TransformManager', () => { const generators: Record = { 'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(), }; - const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock); + const transformManager = new DefaultTransformManager( + generators, + esClientMock, + loggerMock, + spaceId + ); await transformManager.preview('slo-transform-id'); @@ -100,7 +116,12 @@ describe('TransformManager', () => { const generators: Record = { 'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(), }; - const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock); + const transformManager = new DefaultTransformManager( + generators, + esClientMock, + loggerMock, + spaceId + ); await transformManager.start('slo-transform-id'); @@ -114,7 +135,12 @@ describe('TransformManager', () => { const generators: Record = { 'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(), }; - const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock); + const transformManager = new DefaultTransformManager( + generators, + esClientMock, + loggerMock, + spaceId + ); await transformManager.stop('slo-transform-id'); @@ -128,7 +154,12 @@ describe('TransformManager', () => { const generators: Record = { 'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(), }; - const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock); + const transformManager = new DefaultTransformManager( + generators, + esClientMock, + loggerMock, + spaceId + ); await transformManager.uninstall('slo-transform-id'); @@ -143,7 +174,12 @@ describe('TransformManager', () => { const generators: Record = { 'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(), }; - const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock); + const transformManager = new DefaultTransformManager( + generators, + esClientMock, + loggerMock, + spaceId + ); await transformManager.uninstall('slo-transform-id'); diff --git a/x-pack/plugins/observability_solution/observability/server/services/slo/transform_manager.ts b/x-pack/plugins/observability_solution/observability/server/services/slo/transform_manager.ts index 954c7f7d95912..e920a84735d51 100644 --- a/x-pack/plugins/observability_solution/observability/server/services/slo/transform_manager.ts +++ b/x-pack/plugins/observability_solution/observability/server/services/slo/transform_manager.ts @@ -28,7 +28,8 @@ export class DefaultTransformManager implements TransformManager { constructor( private generators: Record, private esClient: ElasticsearchClient, - private logger: Logger + private logger: Logger, + private spaceId: string ) {} async install(slo: SLO): Promise { @@ -38,7 +39,7 @@ export class DefaultTransformManager implements TransformManager { throw new Error(`Unsupported indicator type [${slo.indicator.type}]`); } - const transformParams = generator.getTransformParams(slo); + const transformParams = generator.getTransformParams(slo, this.spaceId); try { await retryTransientEsErrors(() => this.esClient.transform.putTransform(transformParams), { logger: this.logger, @@ -62,7 +63,7 @@ export class DefaultTransformManager implements TransformManager { throw new Error(`Unsupported indicator type [${slo.indicator.type}]`); } - return generator.getTransformParams(slo); + return generator.getTransformParams(slo, this.spaceId); } async preview(transformId: string): Promise { diff --git a/x-pack/plugins/observability_solution/synthetics/common/constants/synthetics/rest_api.ts b/x-pack/plugins/observability_solution/synthetics/common/constants/synthetics/rest_api.ts index c610e511e0e6d..1884f7a2b7de5 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/constants/synthetics/rest_api.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/constants/synthetics/rest_api.ts @@ -45,6 +45,8 @@ export enum SYNTHETICS_API_URLS { CERTS = '/internal/synthetics/certs', + SUGGESTIONS = `/internal/synthetics/suggestions`, + // Project monitor public endpoint SYNTHETICS_MONITORS_PROJECT = '/api/synthetics/project/{projectName}/monitors', SYNTHETICS_MONITORS_PROJECT_UPDATE = '/api/synthetics/project/{projectName}/monitors/_bulk_update', diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/index.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/index.ts index 238b935fdc352..1d1318049800b 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/index.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/index.ts @@ -11,6 +11,7 @@ import { getConnectorTypesRoute } from './default_alerts/get_connector_types'; import { getActionConnectorsRoute } from './default_alerts/get_action_connectors'; import { SyntheticsRestApiRouteFactory } from './types'; import { getSyntheticsCertsRoute } from './certs/get_certificates'; +import { getSyntheticsSuggestionsRoute } from './suggestions/route'; import { getAgentPoliciesRoute } from './settings/private_locations/get_agent_policies'; import { inspectSyntheticsMonitorRoute } from './monitor_cruds/inspect_monitor'; import { deletePackagePolicyRoute } from './monitor_cruds/delete_integration'; @@ -95,6 +96,7 @@ export const syntheticsAppRestApiRoutes: SyntheticsRestApiRouteFactory[] = [ inspectSyntheticsMonitorRoute, getAgentPoliciesRoute, getSyntheticsCertsRoute, + getSyntheticsSuggestionsRoute, getActionConnectorsRoute, getConnectorTypesRoute, ]; diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/suggestions/route.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/suggestions/route.ts new file mode 100644 index 0000000000000..ae729e7361a4d --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/suggestions/route.ts @@ -0,0 +1,157 @@ +/* + * 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 { SyntheticsRestApiRouteFactory } from '../types'; +import { syntheticsMonitorType } from '../../../common/types/saved_objects'; +import { + ConfigKey, + MonitorFiltersResult, + EncryptedSyntheticsMonitorAttributes, +} from '../../../common/runtime_types'; +import { SYNTHETICS_API_URLS } from '../../../common/constants'; +import { QuerySchema, getMonitorFilters, SEARCH_FIELDS } from '../common'; +import { getAllLocations } from '../../synthetics_service/get_all_locations'; + +type Buckets = Array<{ + key: string; + doc_count: number; +}>; + +interface AggsResponse { + locationsAggs: { + buckets: Buckets; + }; + tagsAggs: { + buckets: Buckets; + }; + projectsAggs: { + buckets: Buckets; + }; + monitorIdsAggs: { + buckets: Array<{ + key: string; + doc_count: number; + name: { + hits: { + hits: Array<{ + _source: { + [syntheticsMonitorType]: { + [ConfigKey.NAME]: string; + }; + }; + }>; + }; + }; + }>; + }; +} + +export const getSyntheticsSuggestionsRoute: SyntheticsRestApiRouteFactory< + MonitorFiltersResult +> = () => ({ + method: 'GET', + path: SYNTHETICS_API_URLS.SUGGESTIONS, + validate: { + query: QuerySchema, + }, + handler: async (route): Promise => { + const { + savedObjectsClient, + server: { logger }, + } = route; + const { tags, locations, projects, monitorQueryIds, query } = route.request.query; + + const { filtersStr } = await getMonitorFilters({ + tags, + locations, + projects, + monitorQueryIds, + context: route, + }); + const { allLocations = [] } = await getAllLocations(route); + try { + const data = await savedObjectsClient.find({ + type: syntheticsMonitorType, + perPage: 0, + filter: filtersStr ? `${filtersStr}` : undefined, + aggs, + search: query ? `${query}*` : undefined, + searchFields: SEARCH_FIELDS, + }); + + const { tagsAggs, locationsAggs, projectsAggs, monitorIdsAggs } = + (data?.aggregations as AggsResponse) ?? {}; + const allLocationsMap = new Map(allLocations.map((obj) => [obj.id, obj.label])); + + return { + monitorIds: monitorIdsAggs?.buckets?.map(({ key, doc_count: count, name }) => ({ + label: name?.hits?.hits[0]?._source?.[syntheticsMonitorType]?.[ConfigKey.NAME] || key, + value: key, + count, + })), + tags: + tagsAggs?.buckets?.map(({ key, doc_count: count }) => ({ + label: key, + value: key, + count, + })) ?? [], + locations: + locationsAggs?.buckets?.map(({ key, doc_count: count }) => ({ + label: allLocationsMap.get(key) || key, + value: key, + count, + })) ?? [], + projects: + projectsAggs?.buckets?.map(({ key, doc_count: count }) => ({ + label: key, + value: key, + count, + })) ?? [], + }; + } catch (error) { + logger.error(`Failed to fetch synthetics suggestions: ${error}`); + } + }, +}); + +const aggs = { + tagsAggs: { + terms: { + field: `${syntheticsMonitorType}.attributes.${ConfigKey.TAGS}`, + size: 10000, + exclude: [''], + }, + }, + locationsAggs: { + terms: { + field: `${syntheticsMonitorType}.attributes.${ConfigKey.LOCATIONS}.id`, + size: 10000, + exclude: [''], + }, + }, + projectsAggs: { + terms: { + field: `${syntheticsMonitorType}.attributes.${ConfigKey.PROJECT_ID}`, + size: 10000, + exclude: [''], + }, + }, + monitorIdsAggs: { + terms: { + field: `${syntheticsMonitorType}.attributes.${ConfigKey.MONITOR_QUERY_ID}`, + size: 10000, + exclude: [''], + }, + aggs: { + name: { + top_hits: { + _source: [`${syntheticsMonitorType}.${ConfigKey.NAME}`], + size: 1, + }, + }, + }, + }, +}; diff --git a/x-pack/test/api_integration/apis/slos/get_slo.ts b/x-pack/test/api_integration/apis/slos/get_slo.ts index e61bab90e4cac..e689823349206 100644 --- a/x-pack/test/api_integration/apis/slos/get_slo.ts +++ b/x-pack/test/api_integration/apis/slos/get_slo.ts @@ -93,6 +93,7 @@ export default function ({ getService }: FtrProviderContext) { updatedAt: getResponse.body.updatedAt, version: 2, instanceId: '*', + meta: {}, summary: { sliValue: 0.5, errorBudget: { @@ -151,6 +152,7 @@ export default function ({ getService }: FtrProviderContext) { updatedAt: getResponse.body.updatedAt, version: 2, instanceId: '*', + meta: {}, summary: { sliValue: 0.5, errorBudget: { @@ -219,6 +221,7 @@ export default function ({ getService }: FtrProviderContext) { updatedAt: getResponse.body.updatedAt, version: 2, instanceId: '*', + meta: {}, summary: { sliValue: 0.5, errorBudget: { @@ -286,6 +289,7 @@ export default function ({ getService }: FtrProviderContext) { updatedAt: getResponse.body.updatedAt, version: 2, instanceId: '*', + meta: {}, summary: { sliValue: 0, errorBudget: { diff --git a/x-pack/test/api_integration/apis/synthetics/index.ts b/x-pack/test/api_integration/apis/synthetics/index.ts index 959014c0362cc..2303f38d9baaf 100644 --- a/x-pack/test/api_integration/apis/synthetics/index.ts +++ b/x-pack/test/api_integration/apis/synthetics/index.ts @@ -33,5 +33,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./add_monitor_project_private_location')); loadTestFile(require.resolve('./inspect_monitor')); loadTestFile(require.resolve('./test_now_monitor')); + loadTestFile(require.resolve('./suggestions')); }); } diff --git a/x-pack/test/api_integration/apis/synthetics/suggestions.ts b/x-pack/test/api_integration/apis/synthetics/suggestions.ts new file mode 100644 index 0000000000000..14d514fc17520 --- /dev/null +++ b/x-pack/test/api_integration/apis/synthetics/suggestions.ts @@ -0,0 +1,280 @@ +/* + * 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 { v4 as uuidv4 } from 'uuid'; +import expect from 'expect'; +import { + MonitorFields, + EncryptedSyntheticsSavedMonitor, + ProjectMonitorsRequest, +} from '@kbn/synthetics-plugin/common/runtime_types'; +import { SYNTHETICS_API_URLS } from '@kbn/synthetics-plugin/common/constants'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { getFixtureJson } from './helper/get_fixture_json'; + +export default function ({ getService }: FtrProviderContext) { + describe('SyntheticsSuggestions', function () { + this.tags('skipCloud'); + + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + const security = getService('security'); + + const username = 'admin'; + const roleName = `synthetics_admin`; + const password = `${username}-password`; + const SPACE_ID = `test-space-${uuidv4()}`; + const SPACE_NAME = `test-space-name ${uuidv4()}`; + + let projectMonitors: ProjectMonitorsRequest; + let _monitors: MonitorFields[]; + let monitors: MonitorFields[]; + + const setUniqueIds = (request: ProjectMonitorsRequest) => { + return { + ...request, + monitors: request.monitors.map((monitor) => ({ ...monitor, id: uuidv4() })), + }; + }; + + const deleteMonitor = async (id: string) => { + try { + await supertest + .delete(`/s/${SPACE_ID}${SYNTHETICS_API_URLS.SYNTHETICS_MONITORS}/${id}`) + .set('kbn-xsrf', 'true') + .expect(200); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + }; + + const saveMonitor = async (monitor: MonitorFields) => { + const res = await supertest + .post(`/s/${SPACE_ID}${SYNTHETICS_API_URLS.SYNTHETICS_MONITORS}`) + .set('kbn-xsrf', 'true') + .send(monitor); + + return res.body as EncryptedSyntheticsSavedMonitor; + }; + + before(async () => { + await supertest + .put(SYNTHETICS_API_URLS.SYNTHETICS_ENABLEMENT) + .set('kbn-xsrf', 'true') + .expect(200); + await kibanaServer.spaces.create({ id: SPACE_ID, name: SPACE_NAME }); + await security.role.create(roleName, { + kibana: [ + { + feature: { + uptime: ['all'], + }, + spaces: ['*'], + }, + ], + }); + await security.user.create(username, { + password, + roles: [roleName], + full_name: 'a kibana user', + }); + const { body } = await supertest + .get(`/s/${SPACE_ID}${SYNTHETICS_API_URLS.SYNTHETICS_MONITORS}`) + .set('kbn-xsrf', 'true') + .expect(200); + await Promise.all([ + (body.monitors as EncryptedSyntheticsSavedMonitor[]).map((monitor) => { + return deleteMonitor(monitor.id); + }), + ]); + + _monitors = [getFixtureJson('http_monitor')]; + projectMonitors = setUniqueIds({ + monitors: getFixtureJson('project_icmp_monitor') + .monitors.slice(0, 2) + .map((monitor: any) => ({ ...monitor, privateLocations: [] })), + }); + }); + + beforeEach(() => { + monitors = []; + for (let i = 0; i < 20; i++) { + monitors.push({ + ..._monitors[0], + name: `${_monitors[0].name} ${i}`, + }); + } + }); + + after(async () => { + await kibanaServer.spaces.delete(SPACE_ID); + await security.user.delete(username); + await security.role.delete(roleName); + }); + + it('returns the suggestions', async () => { + let savedMonitors: EncryptedSyntheticsSavedMonitor[] = []; + try { + savedMonitors = await Promise.all(monitors.map(saveMonitor)); + const project = `test-project-${uuidv4()}`; + await supertest + .put( + `/s/${SPACE_ID}${SYNTHETICS_API_URLS.SYNTHETICS_MONITORS_PROJECT_UPDATE.replace( + '{projectName}', + project + )}` + ) + .set('kbn-xsrf', 'true') + .send(projectMonitors) + .expect(200); + const apiResponse = await supertest.get(`/s/${SPACE_ID}${SYNTHETICS_API_URLS.SUGGESTIONS}`); + expect(apiResponse.body).toEqual({ + locations: [ + { + count: 20, + label: 'eu-west-01', + value: 'eu-west-01', + }, + { + count: 20, + label: 'eu-west-02', + value: 'eu-west-02', + }, + { + count: 2, + label: 'Dev Service', + value: 'dev', + }, + ], + monitorIds: expect.arrayContaining([ + ...monitors.map((monitor) => ({ + count: 1, + label: monitor.name, + value: expect.any(String), + })), + ...projectMonitors.monitors.slice(0, 2).map((monitor) => ({ + count: 1, + label: monitor.name, + value: expect.any(String), + })), + ]), + projects: [ + { + count: 2, + label: project, + value: project, + }, + ], + tags: expect.arrayContaining([ + { + count: 21, + label: 'tag1', + value: 'tag1', + }, + { + count: 21, + label: 'tag2', + value: 'tag2', + }, + { + count: 1, + label: 'org:google', + value: 'org:google', + }, + { + count: 1, + label: 'service:smtp', + value: 'service:smtp', + }, + ]), + }); + } finally { + await Promise.all( + savedMonitors.map((monitor) => { + return deleteMonitor(monitor.id); + }) + ); + } + }); + + it('handles query params for projects', async () => { + let savedMonitors: EncryptedSyntheticsSavedMonitor[] = []; + try { + savedMonitors = await Promise.all(monitors.map(saveMonitor)); + const project = `test-project-${uuidv4()}`; + await supertest + .put( + `/s/${SPACE_ID}${SYNTHETICS_API_URLS.SYNTHETICS_MONITORS_PROJECT_UPDATE.replace( + '{projectName}', + project + )}` + ) + .set('kbn-xsrf', 'true') + .send(projectMonitors) + .expect(200); + + const apiResponse = await supertest + .get(`/s/${SPACE_ID}${SYNTHETICS_API_URLS.SUGGESTIONS}`) + .query({ + projects: [project], + }); + + expect(apiResponse.body).toEqual({ + locations: [ + { + count: 2, + label: 'Dev Service', + value: 'dev', + }, + ], + monitorIds: expect.arrayContaining( + projectMonitors.monitors.map((monitor) => ({ + count: 1, + label: monitor.name, + value: expect.any(String), + })) + ), + projects: [ + { + count: 2, + label: project, + value: project, + }, + ], + tags: expect.arrayContaining([ + { + count: 1, + label: 'tag1', + value: 'tag1', + }, + { + count: 1, + label: 'tag2', + value: 'tag2', + }, + { + count: 1, + label: 'org:google', + value: 'org:google', + }, + { + count: 1, + label: 'service:smtp', + value: 'service:smtp', + }, + ]), + }); + } finally { + await Promise.all( + savedMonitors.map((monitor) => { + return deleteMonitor(monitor.id); + }) + ); + } + }); + }); +}