From 7398fe9de0fb43560998d8ad786dcde53fc1dfd2 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Thu, 16 Nov 2023 15:19:57 -0500 Subject: [PATCH] feat(slo): add events chart (#170896) --- .github/CODEOWNERS | 1 + package.json | 1 + packages/kbn-calculate-auto/README.md | 3 + packages/kbn-calculate-auto/index.ts | 9 + packages/kbn-calculate-auto/jest.config.js | 13 ++ packages/kbn-calculate-auto/kibana.jsonc | 5 + packages/kbn-calculate-auto/package.json | 6 + .../src/calculate_auto.test.ts | 31 ++++ .../kbn-calculate-auto/src/calculate_auto.ts | 83 +++++++++ packages/kbn-calculate-auto/tsconfig.json | 17 ++ tsconfig.base.json | 2 + .../kbn-slo-schema/src/rest_specs/slo.ts | 4 + .../kbn-slo-schema/src/schema/common.ts | 17 +- .../plugins/observability/public/constants.ts | 2 + .../public/hooks/slo/query_key_factory.ts | 3 +- .../hooks/slo/use_fetch_active_alerts.ts | 4 +- .../hooks/slo/use_fetch_historical_summary.ts | 5 +- .../hooks/slo/use_fetch_slo_burn_rates.ts | 5 +- .../public/hooks/slo/use_fetch_slo_details.ts | 5 +- .../public/hooks/slo/use_fetch_slo_list.ts | 12 +- .../public/hooks/slo/use_get_preview_data.ts | 53 +++--- .../components/events_chart_panel.tsx | 167 ++++++++++++++++++ .../slo_details/components/slo_details.tsx | 25 ++- .../components/common/data_preview_chart.tsx | 15 +- .../pages/slo_edit/hooks/use_preview.ts | 8 +- .../server/services/slo/get_preview_data.ts | 116 +++++++++--- x-pack/plugins/observability/tsconfig.json | 3 +- yarn.lock | 4 + 28 files changed, 531 insertions(+), 88 deletions(-) create mode 100644 packages/kbn-calculate-auto/README.md create mode 100644 packages/kbn-calculate-auto/index.ts create mode 100644 packages/kbn-calculate-auto/jest.config.js create mode 100644 packages/kbn-calculate-auto/kibana.jsonc create mode 100644 packages/kbn-calculate-auto/package.json create mode 100644 packages/kbn-calculate-auto/src/calculate_auto.test.ts create mode 100644 packages/kbn-calculate-auto/src/calculate_auto.ts create mode 100644 packages/kbn-calculate-auto/tsconfig.json create mode 100644 x-pack/plugins/observability/public/pages/slo_details/components/events_chart_panel.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a17c788699938..cab38f4fd69f6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -54,6 +54,7 @@ x-pack/plugins/banners @elastic/appex-sharedux packages/kbn-bazel-runner @elastic/kibana-operations examples/bfetch_explorer @elastic/appex-sharedux src/plugins/bfetch @elastic/appex-sharedux +packages/kbn-calculate-auto @elastic/obs-ux-management-team x-pack/plugins/canvas @elastic/kibana-presentation x-pack/test/cases_api_integration/common/plugins/cases @elastic/response-ops packages/kbn-cases-components @elastic/response-ops diff --git a/package.json b/package.json index a14a06c2e1881..2bc3b7c7e1fb9 100644 --- a/package.json +++ b/package.json @@ -166,6 +166,7 @@ "@kbn/banners-plugin": "link:x-pack/plugins/banners", "@kbn/bfetch-explorer-plugin": "link:examples/bfetch_explorer", "@kbn/bfetch-plugin": "link:src/plugins/bfetch", + "@kbn/calculate-auto": "link:packages/kbn-calculate-auto", "@kbn/canvas-plugin": "link:x-pack/plugins/canvas", "@kbn/cases-api-integration-test-plugin": "link:x-pack/test/cases_api_integration/common/plugins/cases", "@kbn/cases-components": "link:packages/kbn-cases-components", diff --git a/packages/kbn-calculate-auto/README.md b/packages/kbn-calculate-auto/README.md new file mode 100644 index 0000000000000..4964f65ef1818 --- /dev/null +++ b/packages/kbn-calculate-auto/README.md @@ -0,0 +1,3 @@ +# @kbn/calculate-auto + +Empty package generated by @kbn/generate diff --git a/packages/kbn-calculate-auto/index.ts b/packages/kbn-calculate-auto/index.ts new file mode 100644 index 0000000000000..fb114b4bb315e --- /dev/null +++ b/packages/kbn-calculate-auto/index.ts @@ -0,0 +1,9 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { calculateAuto } from './src/calculate_auto'; diff --git a/packages/kbn-calculate-auto/jest.config.js b/packages/kbn-calculate-auto/jest.config.js new file mode 100644 index 0000000000000..fa25db97a3a43 --- /dev/null +++ b/packages/kbn-calculate-auto/jest.config.js @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-calculate-auto'], +}; diff --git a/packages/kbn-calculate-auto/kibana.jsonc b/packages/kbn-calculate-auto/kibana.jsonc new file mode 100644 index 0000000000000..2ce6c776f1a69 --- /dev/null +++ b/packages/kbn-calculate-auto/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/calculate-auto", + "owner": "@elastic/obs-ux-management-team" +} diff --git a/packages/kbn-calculate-auto/package.json b/packages/kbn-calculate-auto/package.json new file mode 100644 index 0000000000000..71de96101c616 --- /dev/null +++ b/packages/kbn-calculate-auto/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/calculate-auto", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/kbn-calculate-auto/src/calculate_auto.test.ts b/packages/kbn-calculate-auto/src/calculate_auto.test.ts new file mode 100644 index 0000000000000..1ef166bae4fcf --- /dev/null +++ b/packages/kbn-calculate-auto/src/calculate_auto.test.ts @@ -0,0 +1,31 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { calculateAuto } from './calculate_auto'; +import moment, { isDuration } from 'moment'; + +describe('calculateAuto.near(bucket, duration)', () => { + it('should calculate the bucket size for 15 minutes', () => { + const bucketSizeDuration = calculateAuto.near(100, moment.duration(15, 'minutes')); + expect(bucketSizeDuration).not.toBeUndefined(); + expect(isDuration(bucketSizeDuration)).toBeTruthy(); + expect(bucketSizeDuration!.asSeconds()).toBe(10); + }); + it('should calculate the bucket size for an hour', () => { + const bucketSizeDuration = calculateAuto.near(100, moment.duration(1, 'hour')); + expect(bucketSizeDuration).not.toBeUndefined(); + expect(isDuration(bucketSizeDuration)).toBeTruthy(); + expect(bucketSizeDuration!.asSeconds()).toBe(30); + }); + it('should calculate the bucket size for a day', () => { + const bucketSizeDuration = calculateAuto.near(100, moment.duration(1, 'day')); + expect(bucketSizeDuration).not.toBeUndefined(); + expect(isDuration(bucketSizeDuration)).toBeTruthy(); + expect(bucketSizeDuration!.asMinutes()).toBe(10); + }); +}); diff --git a/packages/kbn-calculate-auto/src/calculate_auto.ts b/packages/kbn-calculate-auto/src/calculate_auto.ts new file mode 100644 index 0000000000000..a955765ff15f4 --- /dev/null +++ b/packages/kbn-calculate-auto/src/calculate_auto.ts @@ -0,0 +1,83 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import moment, { isDuration, Duration } from 'moment'; +const d = moment.duration; + +const roundingRules = [ + [d(500, 'ms'), d(100, 'ms')], + [d(5, 'second'), d(1, 'second')], + [d(7.5, 'second'), d(5, 'second')], + [d(15, 'second'), d(10, 'second')], + [d(45, 'second'), d(30, 'second')], + [d(3, 'minute'), d(1, 'minute')], + [d(9, 'minute'), d(5, 'minute')], + [d(20, 'minute'), d(10, 'minute')], + [d(45, 'minute'), d(30, 'minute')], + [d(2, 'hour'), d(1, 'hour')], + [d(6, 'hour'), d(3, 'hour')], + [d(24, 'hour'), d(12, 'hour')], + [d(1, 'week'), d(1, 'd')], + [d(3, 'week'), d(1, 'week')], + [d(1, 'year'), d(1, 'month')], + [d(Infinity, 'year'), d(1, 'year')], +]; + +const reverseRoundingRules = [...roundingRules].reverse(); +type CheckFunction = (bound: Duration, interval: Duration, target: number) => Duration | undefined; + +function findRule(rules: Duration[][], check: CheckFunction, last?: boolean) { + function pickInterval(buckets: number, duration: Duration) { + const target = duration.asMilliseconds() / buckets; + let lastResult = null; + + for (const [end, start] of rules) { + const result = check(end, start, target); + + if (result == null) { + if (!last) continue; + if (lastResult) return lastResult; + break; + } + + if (!last) return result; + lastResult = result; + } + + // fallback to just a number of milliseconds, ensure ms is >= 1 + const ms = Math.max(Math.floor(target), 1); + return moment.duration(ms, 'ms'); + } + + return (buckets: number, duration: Duration) => { + const interval = pickInterval(buckets, duration); + if (isDuration(interval)) return interval; + }; +} + +export const calculateAuto = { + near: findRule( + reverseRoundingRules, + function near(bound, interval, target) { + if (isDuration(bound) && bound.asMilliseconds() > target) return interval; + }, + true + ), + lessThan: findRule( + reverseRoundingRules, + function lessThan(_bound: Duration, interval: Duration, target: number) { + if (interval.asMilliseconds() < target) return interval; + } + ), + atLeast: findRule( + reverseRoundingRules, + function atLeast(_bound: Duration, interval: Duration, target: number) { + if (interval.asMilliseconds() <= target) return interval; + } + ), +}; diff --git a/packages/kbn-calculate-auto/tsconfig.json b/packages/kbn-calculate-auto/tsconfig.json new file mode 100644 index 0000000000000..2f9ddddbeea23 --- /dev/null +++ b/packages/kbn-calculate-auto/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index a92c90b6aeb38..56b25ba43973d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -102,6 +102,8 @@ "@kbn/bfetch-explorer-plugin/*": ["examples/bfetch_explorer/*"], "@kbn/bfetch-plugin": ["src/plugins/bfetch"], "@kbn/bfetch-plugin/*": ["src/plugins/bfetch/*"], + "@kbn/calculate-auto": ["packages/kbn-calculate-auto"], + "@kbn/calculate-auto/*": ["packages/kbn-calculate-auto/*"], "@kbn/canvas-plugin": ["x-pack/plugins/canvas"], "@kbn/canvas-plugin/*": ["x-pack/plugins/canvas/*"], "@kbn/cases-api-integration-test-plugin": ["x-pack/test/cases_api_integration/common/plugins/cases"], 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 155fea1aeb6d0..54e463adc210c 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 @@ -60,6 +60,10 @@ const createSLOResponseSchema = t.type({ const getPreviewDataParamsSchema = t.type({ body: t.type({ indicator: indicatorSchema, + range: t.type({ + start: t.number, + end: t.number, + }), }), }); 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 166f3eab34a92..55e597e759d65 100644 --- a/x-pack/packages/kbn-slo-schema/src/schema/common.ts +++ b/x-pack/packages/kbn-slo-schema/src/schema/common.ts @@ -50,10 +50,19 @@ const historicalSummarySchema = t.intersection([ summarySchema, ]); -const previewDataSchema = t.type({ - date: dateType, - sliValue: t.number, -}); +const previewDataSchema = t.intersection([ + t.type({ + date: dateType, + sliValue: t.number, + }), + t.partial({ + events: t.type({ + good: t.number, + bad: t.number, + total: t.number, + }), + }), +]); const dateRangeSchema = t.type({ from: dateType, to: dateType }); diff --git a/x-pack/plugins/observability/public/constants.ts b/x-pack/plugins/observability/public/constants.ts index ec7c51f10c3cf..40d24b18340bc 100644 --- a/x-pack/plugins/observability/public/constants.ts +++ b/x-pack/plugins/observability/public/constants.ts @@ -7,3 +7,5 @@ export const DEFAULT_INTERVAL = '60s'; export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD HH:mm'; +export const SLO_LONG_REFETCH_INTERVAL = 60 * 1000; // 1 minute +export const SLO_SHORT_REFETCH_INTERVAL = 5 * 1000; // 5 seconds diff --git a/x-pack/plugins/observability/public/hooks/slo/query_key_factory.ts b/x-pack/plugins/observability/public/hooks/slo/query_key_factory.ts index feec4d475e3a8..b56a0576396f9 100644 --- a/x-pack/plugins/observability/public/hooks/slo/query_key_factory.ts +++ b/x-pack/plugins/observability/public/hooks/slo/query_key_factory.ts @@ -32,7 +32,8 @@ export const sloKeys = { globalDiagnosis: () => [...sloKeys.all, 'globalDiagnosis'] as const, burnRates: (sloId: string, instanceId: string | undefined) => [...sloKeys.all, 'burnRates', sloId, instanceId] as const, - preview: (indicator?: Indicator) => [...sloKeys.all, 'preview', indicator] as const, + preview: (indicator: Indicator, range: { start: number; end: number }) => + [...sloKeys.all, 'preview', indicator, range] as const, }; export type SloKeys = typeof sloKeys; diff --git a/x-pack/plugins/observability/public/hooks/slo/use_fetch_active_alerts.ts b/x-pack/plugins/observability/public/hooks/slo/use_fetch_active_alerts.ts index 2d234b57ab8ed..33ae5ffac2637 100644 --- a/x-pack/plugins/observability/public/hooks/slo/use_fetch_active_alerts.ts +++ b/x-pack/plugins/observability/public/hooks/slo/use_fetch_active_alerts.ts @@ -12,6 +12,7 @@ import { ALL_VALUE, SLOResponse } from '@kbn/slo-schema'; import { AlertConsumers } from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names'; import { useKibana } from '../../utils/kibana_react'; import { sloKeys } from './query_key_factory'; +import { SLO_LONG_REFETCH_INTERVAL } from '../../constants'; type SLO = Pick; @@ -71,7 +72,6 @@ interface FindApiResponse { }; } -const LONG_REFETCH_INTERVAL = 1000 * 60; // 1 minute const EMPTY_ACTIVE_ALERTS_MAP = new ActiveAlerts(); export function useFetchActiveAlerts({ @@ -141,7 +141,7 @@ export function useFetchActiveAlerts({ } }, refetchOnWindowFocus: false, - refetchInterval: shouldRefetch ? LONG_REFETCH_INTERVAL : undefined, + refetchInterval: shouldRefetch ? SLO_LONG_REFETCH_INTERVAL : undefined, enabled: Boolean(sloIdsAndInstanceIds.length), }); diff --git a/x-pack/plugins/observability/public/hooks/slo/use_fetch_historical_summary.ts b/x-pack/plugins/observability/public/hooks/slo/use_fetch_historical_summary.ts index a20b721f11bbd..63bdc55d7eaf8 100644 --- a/x-pack/plugins/observability/public/hooks/slo/use_fetch_historical_summary.ts +++ b/x-pack/plugins/observability/public/hooks/slo/use_fetch_historical_summary.ts @@ -10,6 +10,7 @@ import { FetchHistoricalSummaryResponse } from '@kbn/slo-schema'; import { useKibana } from '../../utils/kibana_react'; import { sloKeys } from './query_key_factory'; +import { SLO_LONG_REFETCH_INTERVAL } from '../../constants'; export interface UseFetchHistoricalSummaryResponse { data: FetchHistoricalSummaryResponse | undefined; @@ -25,8 +26,6 @@ export interface Params { shouldRefetch?: boolean; } -const LONG_REFETCH_INTERVAL = 1000 * 60; // 1 minute - export function useFetchHistoricalSummary({ list = [], shouldRefetch, @@ -50,7 +49,7 @@ export function useFetchHistoricalSummary({ // ignore error } }, - refetchInterval: shouldRefetch ? LONG_REFETCH_INTERVAL : undefined, + refetchInterval: shouldRefetch ? SLO_LONG_REFETCH_INTERVAL : undefined, refetchOnWindowFocus: false, keepPreviousData: true, }); diff --git a/x-pack/plugins/observability/public/hooks/slo/use_fetch_slo_burn_rates.ts b/x-pack/plugins/observability/public/hooks/slo/use_fetch_slo_burn_rates.ts index 20092609bdfa8..07ba25e0e5249 100644 --- a/x-pack/plugins/observability/public/hooks/slo/use_fetch_slo_burn_rates.ts +++ b/x-pack/plugins/observability/public/hooks/slo/use_fetch_slo_burn_rates.ts @@ -13,6 +13,7 @@ import { import { ALL_VALUE, GetSLOBurnRatesResponse, SLOWithSummaryResponse } from '@kbn/slo-schema'; import { useKibana } from '../../utils/kibana_react'; import { sloKeys } from './query_key_factory'; +import { SLO_LONG_REFETCH_INTERVAL } from '../../constants'; export interface UseFetchSloBurnRatesResponse { isInitialLoading: boolean; @@ -26,8 +27,6 @@ export interface UseFetchSloBurnRatesResponse { ) => Promise>; } -const LONG_REFETCH_INTERVAL = 1000 * 60; // 1 minute - interface UseFetchSloBurnRatesParams { slo: SLOWithSummaryResponse; windows: Array<{ name: string; duration: string }>; @@ -58,7 +57,7 @@ export function useFetchSloBurnRates({ // ignore error } }, - refetchInterval: shouldRefetch ? LONG_REFETCH_INTERVAL : undefined, + refetchInterval: shouldRefetch ? SLO_LONG_REFETCH_INTERVAL : undefined, refetchOnWindowFocus: false, keepPreviousData: true, } diff --git a/x-pack/plugins/observability/public/hooks/slo/use_fetch_slo_details.ts b/x-pack/plugins/observability/public/hooks/slo/use_fetch_slo_details.ts index ad5511433522e..76864996168bb 100644 --- a/x-pack/plugins/observability/public/hooks/slo/use_fetch_slo_details.ts +++ b/x-pack/plugins/observability/public/hooks/slo/use_fetch_slo_details.ts @@ -12,6 +12,7 @@ import { RefetchQueryFilters, useQuery, } from '@tanstack/react-query'; +import { SLO_LONG_REFETCH_INTERVAL } from '../../constants'; import { useKibana } from '../../utils/kibana_react'; import { sloKeys } from './query_key_factory'; @@ -27,8 +28,6 @@ export interface UseFetchSloDetailsResponse { ) => Promise>; } -const LONG_REFETCH_INTERVAL = 1000 * 60; // 1 minute - export function useFetchSloDetails({ sloId, instanceId, @@ -59,7 +58,7 @@ export function useFetchSloDetails({ }, keepPreviousData: true, enabled: Boolean(sloId), - refetchInterval: shouldRefetch ? LONG_REFETCH_INTERVAL : undefined, + refetchInterval: shouldRefetch ? SLO_LONG_REFETCH_INTERVAL : undefined, refetchOnWindowFocus: false, } ); diff --git a/x-pack/plugins/observability/public/hooks/slo/use_fetch_slo_list.ts b/x-pack/plugins/observability/public/hooks/slo/use_fetch_slo_list.ts index 8f7a9e21b20cc..a05ec3c616950 100644 --- a/x-pack/plugins/observability/public/hooks/slo/use_fetch_slo_list.ts +++ b/x-pack/plugins/observability/public/hooks/slo/use_fetch_slo_list.ts @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { FindSLOResponse } from '@kbn/slo-schema'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; +import { SLO_LONG_REFETCH_INTERVAL, SLO_SHORT_REFETCH_INTERVAL } from '../../constants'; import { useKibana } from '../../utils/kibana_react'; import { sloKeys } from './query_key_factory'; @@ -30,9 +31,6 @@ export interface UseFetchSloListResponse { data: FindSLOResponse | undefined; } -const SHORT_REFETCH_INTERVAL = 1000 * 5; // 5 seconds -const LONG_REFETCH_INTERVAL = 1000 * 60; // 1 minute - export function useFetchSloList({ kqlQuery = '', page = 1, @@ -45,7 +43,9 @@ export function useFetchSloList({ notifications: { toasts }, } = useKibana().services; const queryClient = useQueryClient(); - const [stateRefetchInterval, setStateRefetchInterval] = useState(SHORT_REFETCH_INTERVAL); + const [stateRefetchInterval, setStateRefetchInterval] = useState( + SLO_SHORT_REFETCH_INTERVAL + ); const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({ queryKey: sloKeys.list({ kqlQuery, page, sortBy, sortDirection }), @@ -81,9 +81,9 @@ export function useFetchSloList({ } if (results.find((slo) => slo.summary.status === 'NO_DATA' || !slo.summary)) { - setStateRefetchInterval(SHORT_REFETCH_INTERVAL); + setStateRefetchInterval(SLO_SHORT_REFETCH_INTERVAL); } else { - setStateRefetchInterval(LONG_REFETCH_INTERVAL); + setStateRefetchInterval(SLO_LONG_REFETCH_INTERVAL); } }, onError: (error: Error) => { diff --git a/x-pack/plugins/observability/public/hooks/slo/use_get_preview_data.ts b/x-pack/plugins/observability/public/hooks/slo/use_get_preview_data.ts index 0dfe9cbebc82d..3a3b5d91871ea 100644 --- a/x-pack/plugins/observability/public/hooks/slo/use_get_preview_data.ts +++ b/x-pack/plugins/observability/public/hooks/slo/use_get_preview_data.ts @@ -6,57 +6,48 @@ */ import { GetPreviewDataResponse, Indicator } from '@kbn/slo-schema'; -import { - QueryObserverResult, - RefetchOptions, - RefetchQueryFilters, - useQuery, -} from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { useKibana } from '../../utils/kibana_react'; import { sloKeys } from './query_key_factory'; export interface UseGetPreviewData { data: GetPreviewDataResponse | undefined; isInitialLoading: boolean; - isRefetching: boolean; isLoading: boolean; isSuccess: boolean; isError: boolean; - refetch: ( - options?: (RefetchOptions & RefetchQueryFilters) | undefined - ) => Promise>; } -export function useGetPreviewData(isValid: boolean, indicator: Indicator): UseGetPreviewData { +export function useGetPreviewData( + isValid: boolean, + indicator: Indicator, + range: { start: number; end: number } +): UseGetPreviewData { const { http } = useKibana().services; - const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data, refetch } = useQuery( - { - queryKey: sloKeys.preview(indicator), - queryFn: async ({ signal }) => { - const response = await http.post( - '/internal/observability/slos/_preview', - { - body: JSON.stringify({ indicator }), - signal, - } - ); + const { isInitialLoading, isLoading, isError, isSuccess, data } = useQuery({ + queryKey: sloKeys.preview(indicator, range), + queryFn: async ({ signal }) => { + const response = await http.post( + '/internal/observability/slos/_preview', + { + body: JSON.stringify({ indicator, range }), + signal, + } + ); - return Array.isArray(response) ? response : []; - }, - retry: false, - refetchOnWindowFocus: false, - enabled: isValid, - } - ); + return Array.isArray(response) ? response : []; + }, + retry: false, + refetchOnWindowFocus: false, + enabled: isValid, + }); return { data, isLoading, - isRefetching, isInitialLoading, isSuccess, isError, - refetch, }; } diff --git a/x-pack/plugins/observability/public/pages/slo_details/components/events_chart_panel.tsx b/x-pack/plugins/observability/public/pages/slo_details/components/events_chart_panel.tsx new file mode 100644 index 0000000000000..10655cd98b121 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/slo_details/components/events_chart_panel.tsx @@ -0,0 +1,167 @@ +/* + * 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, + Position, + ScaleType, + Settings, + Tooltip, + TooltipType, +} from '@elastic/charts'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLoadingChart, + EuiPanel, + EuiText, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import { useActiveCursor } from '@kbn/charts-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { SLOWithSummaryResponse } from '@kbn/slo-schema'; +import moment from 'moment'; +import React, { useRef } from 'react'; +import { useGetPreviewData } from '../../../hooks/slo/use_get_preview_data'; +import { useKibana } from '../../../utils/kibana_react'; + +export interface Props { + slo: SLOWithSummaryResponse; + range: { + start: number; + end: number; + }; +} + +export function EventsChartPanel({ slo, range }: Props) { + const { charts, uiSettings } = useKibana().services; + const { euiTheme } = useEuiTheme(); + const { isLoading, data } = useGetPreviewData(true, slo.indicator, range); + const theme = charts.theme.useChartsTheme(); + const baseTheme = charts.theme.useChartsBaseTheme(); + const chartRef = useRef(null); + const handleCursorUpdate = useActiveCursor(charts.activeCursor, chartRef, { + isDateHistogram: true, + }); + + const dateFormat = uiSettings.get('dateFormat'); + + return ( + + + + + +

+ {i18n.translate('xpack.observability.slo.sloDetails.eventsChartPanel.title', { + defaultMessage: 'Good vs bad events', + })} +

+
+
+ + + {i18n.translate('xpack.observability.slo.sloDetails.eventsChartPanel.duration', { + defaultMessage: 'Last 24h', + })} + + +
+ + + {isLoading && } + + {!isLoading && ( + + + + } + onPointerUpdate={handleCursorUpdate} + externalPointerEvents={{ + tooltip: { visible: true }, + }} + pointerUpdateDebounce={0} + pointerUpdateTrigger={'x'} + locale={i18n.getLocale()} + /> + + moment(d).format(dateFormat)} + /> + numeral(d).format('0,0')} + /> + + ({ + 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/public/pages/slo_details/components/slo_details.tsx b/x-pack/plugins/observability/public/pages/slo_details/components/slo_details.tsx index e05c9c7403dd6..3bb1ea99ad0cb 100644 --- a/x-pack/plugins/observability/public/pages/slo_details/components/slo_details.tsx +++ b/x-pack/plugins/observability/public/pages/slo_details/components/slo_details.tsx @@ -15,7 +15,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema'; -import React, { Fragment, useState } from 'react'; +import React, { Fragment, useEffect, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { useFetchActiveAlerts } from '../../../hooks/slo/use_fetch_active_alerts'; @@ -23,6 +23,7 @@ import { useFetchHistoricalSummary } from '../../../hooks/slo/use_fetch_historic import { formatHistoricalData } from '../../../utils/slo/chart_data_formatter'; import { BurnRates } from './burn_rates'; import { ErrorBudgetChartPanel } from './error_budget_chart_panel'; +import { EventsChartPanel } from './events_chart_panel'; import { Overview } from './overview/overview'; import { SliChartPanel } from './sli_chart_panel'; import { SloDetailsAlerts } from './slo_detail_alerts'; @@ -35,6 +36,7 @@ export interface Props { const TAB_ID_URL_PARAM = 'tabId'; const OVERVIEW_TAB_ID = 'overview'; const ALERTS_TAB_ID = 'alerts'; +const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000; type TabId = typeof OVERVIEW_TAB_ID | typeof ALERTS_TAB_ID; @@ -56,6 +58,22 @@ export function SloDetails({ slo, isAutoRefreshing }: Props) { historicalSummary.instanceId === (slo.instanceId ?? ALL_VALUE) ); + const [range, setRange] = useState({ + start: new Date().getTime() - DAY_IN_MILLISECONDS, + end: new Date().getTime(), + }); + + useEffect(() => { + let intervalId: any; + if (isAutoRefreshing) { + intervalId = setInterval(() => { + setRange({ start: new Date().getTime() - DAY_IN_MILLISECONDS, end: new Date().getTime() }); + }, 60 * 1000); + } + + return () => clearInterval(intervalId); + }, [isAutoRefreshing]); + const errorBudgetBurnDownData = formatHistoricalData( sloHistoricalSummary?.data, 'error_budget_remaining' @@ -94,6 +112,11 @@ export function SloDetails({ slo, isAutoRefreshing }: Props) { slo={slo} /> + {slo.indicator.type !== 'sli.metric.timeslice' ? ( + + + + ) : null} diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx index 7dc2e00f60829..dfa52e6e572a0 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx @@ -29,11 +29,11 @@ import { } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { max, min } from 'lodash'; import moment from 'moment'; -import React from 'react'; +import React, { useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { min, max } from 'lodash'; import { useKibana } from '../../../../utils/kibana_react'; import { useDebouncedGetPreviewData } from '../../hooks/use_preview'; import { useSectionFormValidation } from '../../hooks/use_section_form_validation'; @@ -47,6 +47,8 @@ interface DataPreviewChartProps { thresholdMessage?: string; } +const ONE_HOUR_IN_MILLISECONDS = 1 * 60 * 60 * 1000; + export function DataPreviewChart({ formatPattern, threshold, @@ -63,12 +65,17 @@ export function DataPreviewChart({ watch, }); + const [range, _] = useState({ + start: new Date().getTime() - ONE_HOUR_IN_MILLISECONDS, + end: new Date().getTime(), + }); + const { data: previewData, isLoading: isPreviewLoading, isSuccess, isError, - } = useDebouncedGetPreviewData(isIndicatorSectionValid, watch('indicator')); + } = useDebouncedGetPreviewData(isIndicatorSectionValid, watch('indicator'), range); const theme = charts.theme.useChartsTheme(); const baseTheme = charts.theme.useChartsBaseTheme(); diff --git a/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_preview.ts b/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_preview.ts index 72cad2455525f..0aa79a391433f 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_preview.ts +++ b/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_preview.ts @@ -10,7 +10,11 @@ import { debounce } from 'lodash'; import { useCallback, useEffect, useState } from 'react'; import { useGetPreviewData } from '../../../hooks/slo/use_get_preview_data'; -export function useDebouncedGetPreviewData(isIndicatorValid: boolean, indicator: Indicator) { +export function useDebouncedGetPreviewData( + isIndicatorValid: boolean, + indicator: Indicator, + range: { start: number; end: number } +) { const serializedIndicator = JSON.stringify(indicator); const [indicatorState, setIndicatorState] = useState(serializedIndicator); @@ -25,5 +29,5 @@ export function useDebouncedGetPreviewData(isIndicatorValid: boolean, indicator: } }, [indicatorState, serializedIndicator, store]); - return useGetPreviewData(isIndicatorValid, JSON.parse(indicatorState)); + return useGetPreviewData(isIndicatorValid, JSON.parse(indicatorState), range); } diff --git a/x-pack/plugins/observability/server/services/slo/get_preview_data.ts b/x-pack/plugins/observability/server/services/slo/get_preview_data.ts index 2fd80c09d3875..ea8be2c3b6ef2 100644 --- a/x-pack/plugins/observability/server/services/slo/get_preview_data.ts +++ b/x-pack/plugins/observability/server/services/slo/get_preview_data.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { calculateAuto } from '@kbn/calculate-auto'; import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; import { @@ -18,6 +19,7 @@ import { TimesliceMetricIndicator, } from '@kbn/slo-schema'; import { assertNever } from '@kbn/std'; +import moment from 'moment'; import { APMTransactionDurationIndicator } from '../../domain/models'; import { computeSLI } from '../../domain/services'; import { InvalidQueryError } from '../../errors'; @@ -27,11 +29,19 @@ import { GetTimesliceMetricIndicatorAggregation, } from './aggregations'; +interface Options { + range: { + start: number; + end: number; + }; + interval: string; +} export class GetPreviewData { constructor(private esClient: ElasticsearchClient) {} private async getAPMTransactionDurationPreviewData( - indicator: APMTransactionDurationIndicator + indicator: APMTransactionDurationIndicator, + options: Options ): Promise { const filter = []; if (indicator.params.service !== ALL_VALUE) @@ -61,7 +71,7 @@ export class GetPreviewData { query: { bool: { filter: [ - { range: { '@timestamp': { gte: 'now-60m' } } }, + { range: { '@timestamp': { gte: options.range.start, lte: options.range.end } } }, { terms: { 'processor.event': ['metric'] } }, { term: { 'metricset.name': 'transaction' } }, { exists: { field: 'transaction.duration.histogram' } }, @@ -73,7 +83,7 @@ export class GetPreviewData { perMinute: { date_histogram: { field: '@timestamp', - fixed_interval: '1m', + fixed_interval: options.interval, }, aggs: { _good: { @@ -105,11 +115,17 @@ export class GetPreviewData { date: bucket.key_as_string, sliValue: !!bucket.good && !!bucket.total ? computeSLI(bucket.good.value, bucket.total.value) : null, + events: { + good: bucket.good?.value ?? 0, + bad: (bucket.total?.value ?? 0) - (bucket.good?.value ?? 0), + total: bucket.total?.value ?? 0, + }, })); } private async getAPMTransactionErrorPreviewData( - indicator: APMTransactionErrorRateIndicator + indicator: APMTransactionErrorRateIndicator, + options: Options ): Promise { const filter = []; if (indicator.params.service !== ALL_VALUE) @@ -137,7 +153,7 @@ export class GetPreviewData { query: { bool: { filter: [ - { range: { '@timestamp': { gte: 'now-60m' } } }, + { range: { '@timestamp': { gte: options.range.start, lte: options.range.end } } }, { term: { 'metricset.name': 'transaction' } }, { terms: { 'event.outcome': ['success', 'failure'] } }, ...filter, @@ -148,7 +164,7 @@ export class GetPreviewData { perMinute: { date_histogram: { field: '@timestamp', - fixed_interval: '1m', + fixed_interval: options.interval, }, aggs: { good: { @@ -179,28 +195,37 @@ export class GetPreviewData { !!bucket.good && !!bucket.total ? computeSLI(bucket.good.doc_count, bucket.total.doc_count) : null, + events: { + good: bucket.good?.doc_count ?? 0, + bad: (bucket.total?.doc_count ?? 0) - (bucket.good?.doc_count ?? 0), + total: bucket.total?.doc_count ?? 0, + }, })); } private async getHistogramPreviewData( - indicator: HistogramIndicator + indicator: HistogramIndicator, + options: Options ): Promise { const getHistogramIndicatorAggregations = new GetHistogramIndicatorAggregation(indicator); const filterQuery = getElastichsearchQueryOrThrow(indicator.params.filter); const timestampField = indicator.params.timestampField; - const options = { + const result = await this.esClient.search({ index: indicator.params.index, size: 0, query: { bool: { - filter: [{ range: { [timestampField]: { gte: 'now-60m' } } }, filterQuery], + filter: [ + { range: { [timestampField]: { gte: options.range.start, lte: options.range.end } } }, + filterQuery, + ], }, }, aggs: { perMinute: { date_histogram: { field: timestampField, - fixed_interval: '1m', + fixed_interval: options.interval, }, aggs: { ...getHistogramIndicatorAggregations.execute({ @@ -214,19 +239,24 @@ export class GetPreviewData { }, }, }, - }; - const result = await this.esClient.search(options); + }); // @ts-ignore buckets is not improperly typed return result.aggregations?.perMinute.buckets.map((bucket) => ({ date: bucket.key_as_string, sliValue: !!bucket.good && !!bucket.total ? computeSLI(bucket.good.value, bucket.total.value) : null, + events: { + good: bucket.good?.value ?? 0, + bad: (bucket.total?.value ?? 0) - (bucket.good?.value ?? 0), + total: bucket.total?.value ?? 0, + }, })); } private async getCustomMetricPreviewData( - indicator: MetricCustomIndicator + indicator: MetricCustomIndicator, + options: Options ): Promise { const timestampField = indicator.params.timestampField; const filterQuery = getElastichsearchQueryOrThrow(indicator.params.filter); @@ -236,14 +266,17 @@ export class GetPreviewData { size: 0, query: { bool: { - filter: [{ range: { [timestampField]: { gte: 'now-60m' } } }, filterQuery], + filter: [ + { range: { [timestampField]: { gte: options.range.start, lte: options.range.end } } }, + filterQuery, + ], }, }, aggs: { perMinute: { date_histogram: { field: timestampField, - fixed_interval: '1m', + fixed_interval: options.interval, }, aggs: { ...getCustomMetricIndicatorAggregation.execute({ @@ -264,11 +297,17 @@ export class GetPreviewData { date: bucket.key_as_string, sliValue: !!bucket.good && !!bucket.total ? computeSLI(bucket.good.value, bucket.total.value) : null, + events: { + good: bucket.good?.value ?? 0, + bad: (bucket.total?.value ?? 0) - (bucket.good?.value ?? 0), + total: bucket.total?.value ?? 0, + }, })); } private async getTimesliceMetricPreviewData( - indicator: TimesliceMetricIndicator + indicator: TimesliceMetricIndicator, + options: Options ): Promise { const timestampField = indicator.params.timestampField; const filterQuery = getElastichsearchQueryOrThrow(indicator.params.filter); @@ -280,14 +319,17 @@ export class GetPreviewData { size: 0, query: { bool: { - filter: [{ range: { [timestampField]: { gte: 'now-60m' } } }, filterQuery], + filter: [ + { range: { [timestampField]: { gte: options.range.start, lte: options.range.end } } }, + filterQuery, + ], }, }, aggs: { perMinute: { date_histogram: { field: timestampField, - fixed_interval: '1m', + fixed_interval: options.interval, }, aggs: { ...getCustomMetricIndicatorAggregation.execute('metric'), @@ -304,7 +346,8 @@ export class GetPreviewData { } private async getCustomKQLPreviewData( - indicator: KQLCustomIndicator + indicator: KQLCustomIndicator, + options: Options ): Promise { const filterQuery = getElastichsearchQueryOrThrow(indicator.params.filter); const goodQuery = getElastichsearchQueryOrThrow(indicator.params.good); @@ -315,14 +358,17 @@ export class GetPreviewData { size: 0, query: { bool: { - filter: [{ range: { [timestampField]: { gte: 'now-60m' } } }, filterQuery], + filter: [ + { range: { [timestampField]: { gte: options.range.start, lte: options.range.end } } }, + filterQuery, + ], }, }, aggs: { perMinute: { date_histogram: { field: timestampField, - fixed_interval: '1m', + fixed_interval: options.interval, }, aggs: { good: { filter: goodQuery }, @@ -339,25 +385,41 @@ export class GetPreviewData { !!bucket.good && !!bucket.total ? computeSLI(bucket.good.doc_count, bucket.total.doc_count) : null, + events: { + good: bucket.good?.doc_count ?? 0, + bad: (bucket.total?.doc_count ?? 0) - (bucket.good?.doc_count ?? 0), + total: bucket.total?.doc_count ?? 0, + }, })); } public async execute(params: GetPreviewDataParams): Promise { try { + const bucketSize = Math.max( + calculateAuto + .near(100, moment.duration(params.range.end - params.range.start, 'ms')) + ?.asMinutes() ?? 0, + 1 + ); + const options: Options = { + range: params.range, + interval: `${bucketSize}m`, + }; + const type = params.indicator.type; switch (type) { case 'sli.apm.transactionDuration': - return this.getAPMTransactionDurationPreviewData(params.indicator); + return this.getAPMTransactionDurationPreviewData(params.indicator, options); case 'sli.apm.transactionErrorRate': - return this.getAPMTransactionErrorPreviewData(params.indicator); + return this.getAPMTransactionErrorPreviewData(params.indicator, options); case 'sli.kql.custom': - return this.getCustomKQLPreviewData(params.indicator); + return this.getCustomKQLPreviewData(params.indicator, options); case 'sli.histogram.custom': - return this.getHistogramPreviewData(params.indicator); + return this.getHistogramPreviewData(params.indicator, options); case 'sli.metric.custom': - return this.getCustomMetricPreviewData(params.indicator); + return this.getCustomMetricPreviewData(params.indicator, options); case 'sli.metric.timeslice': - return this.getTimesliceMetricPreviewData(params.indicator); + return this.getTimesliceMetricPreviewData(params.indicator, options); default: assertNever(type); } diff --git a/x-pack/plugins/observability/tsconfig.json b/x-pack/plugins/observability/tsconfig.json index 57a1deb071337..f2bd514a4c4ef 100644 --- a/x-pack/plugins/observability/tsconfig.json +++ b/x-pack/plugins/observability/tsconfig.json @@ -94,7 +94,8 @@ "@kbn/core-chrome-browser", "@kbn/lens-embeddable-utils", "@kbn/serverless", - "@kbn/dashboard-plugin" + "@kbn/dashboard-plugin", + "@kbn/calculate-auto" ], "exclude": [ "target/**/*" diff --git a/yarn.lock b/yarn.lock index b35cb584ca5cc..6bb6ba84d79b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3108,6 +3108,10 @@ version "0.0.0" uid "" +"@kbn/calculate-auto@link:packages/kbn-calculate-auto": + version "0.0.0" + uid "" + "@kbn/canvas-plugin@link:x-pack/plugins/canvas": version "0.0.0" uid ""