mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
feat(slo): health status (#181351)
This commit is contained in:
parent
b8d8c737e6
commit
06d32af345
23 changed files with 1049 additions and 97 deletions
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* 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 * as t from 'io-ts';
|
||||
import { healthStatusSchema, sloIdSchema, stateSchema } from '../../schema';
|
||||
import { allOrAnyString } from '../../schema/common';
|
||||
|
||||
const fetchSLOHealthResponseSchema = t.array(
|
||||
t.type({
|
||||
sloId: sloIdSchema,
|
||||
sloInstanceId: allOrAnyString,
|
||||
sloRevision: t.number,
|
||||
state: stateSchema,
|
||||
health: t.type({
|
||||
overall: healthStatusSchema,
|
||||
rollup: healthStatusSchema,
|
||||
summary: healthStatusSchema,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const fetchSLOHealthParamsSchema = t.type({
|
||||
body: t.type({
|
||||
list: t.array(t.type({ sloId: sloIdSchema, sloInstanceId: allOrAnyString })),
|
||||
}),
|
||||
});
|
||||
|
||||
type FetchSLOHealthResponse = t.OutputOf<typeof fetchSLOHealthResponseSchema>;
|
||||
type FetchSLOHealthParams = t.TypeOf<typeof fetchSLOHealthParamsSchema.props.body>;
|
||||
|
||||
export { fetchSLOHealthParamsSchema, fetchSLOHealthResponseSchema };
|
||||
export type { FetchSLOHealthResponse, FetchSLOHealthParams };
|
|
@ -21,3 +21,4 @@ export * from './delete_instance';
|
|||
export * from './fetch_historical_summary';
|
||||
export * from './put_settings';
|
||||
export * from './get_suggestions';
|
||||
export * from './get_slo_health';
|
||||
|
|
18
x-pack/packages/kbn-slo-schema/src/schema/health.ts
Normal file
18
x-pack/packages/kbn-slo-schema/src/schema/health.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* 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 * as t from 'io-ts';
|
||||
|
||||
const healthStatusSchema = t.union([t.literal('healthy'), t.literal('unhealthy')]);
|
||||
const stateSchema = t.union([
|
||||
t.literal('no_data'),
|
||||
t.literal('indexing'),
|
||||
t.literal('running'),
|
||||
t.literal('stale'),
|
||||
]);
|
||||
|
||||
export { healthStatusSchema, stateSchema };
|
|
@ -11,3 +11,4 @@ export * from './indicators';
|
|||
export * from './time_window';
|
||||
export * from './slo';
|
||||
export * from './settings';
|
||||
export * from './health';
|
||||
|
|
|
@ -46,6 +46,8 @@ export const sloKeys = {
|
|||
definitions: (search: string, page: number, perPage: number, includeOutdatedOnly: boolean) =>
|
||||
[...sloKeys.all, 'definitions', search, page, perPage, includeOutdatedOnly] as const,
|
||||
globalDiagnosis: () => [...sloKeys.all, 'globalDiagnosis'] as const,
|
||||
health: (list: Array<{ sloId: string; sloInstanceId: string }>) =>
|
||||
[...sloKeys.all, 'health', list] as const,
|
||||
burnRates: (
|
||||
sloId: string,
|
||||
instanceId: string | undefined,
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* 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, FetchSLOHealthResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useKibana } from '../utils/kibana_react';
|
||||
import { sloKeys } from './query_key_factory';
|
||||
|
||||
export interface UseFetchSloHealth {
|
||||
data: FetchSLOHealthResponse | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export interface Params {
|
||||
list: SLOWithSummaryResponse[];
|
||||
}
|
||||
|
||||
export function useFetchSloHealth({ list }: Params): UseFetchSloHealth {
|
||||
const { http } = useKibana().services;
|
||||
const payload = list.map((slo) => ({
|
||||
sloId: slo.id,
|
||||
sloInstanceId: slo.instanceId ?? ALL_VALUE,
|
||||
}));
|
||||
|
||||
const { isLoading, isError, data } = useQuery({
|
||||
queryKey: sloKeys.health(payload),
|
||||
queryFn: async ({ signal }) => {
|
||||
try {
|
||||
const response = await http.post<FetchSLOHealthResponse>(
|
||||
'/internal/observability/slos/_health',
|
||||
{
|
||||
body: JSON.stringify({ list: payload }),
|
||||
signal,
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// ignore error
|
||||
}
|
||||
},
|
||||
enabled: Boolean(list.length > 0),
|
||||
refetchOnWindowFocus: false,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
};
|
||||
}
|
|
@ -18,6 +18,7 @@ import { EventsChartPanel } from './events_chart_panel';
|
|||
import { Overview } from './overview/overview';
|
||||
import { SliChartPanel } from './sli_chart_panel';
|
||||
import { SloDetailsAlerts } from './slo_detail_alerts';
|
||||
import { SloHealthCallout } from './slo_health_callout';
|
||||
import { SloRemoteCallout } from './slo_remote_callout';
|
||||
|
||||
export const TAB_ID_URL_PARAM = 'tabId';
|
||||
|
@ -126,6 +127,7 @@ export function SloDetails({ slo, isAutoRefreshing, selectedTabId }: Props) {
|
|||
return selectedTabId === OVERVIEW_TAB_ID ? (
|
||||
<EuiFlexGroup direction="column" gutterSize="xl">
|
||||
<SloRemoteCallout slo={slo} />
|
||||
<SloHealthCallout slo={slo} />
|
||||
<EuiFlexItem>
|
||||
<Overview slo={slo} />
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiButton,
|
||||
EuiButtonIcon,
|
||||
EuiCallOut,
|
||||
EuiCopy,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
|
||||
import React from 'react';
|
||||
import { getSLOSummaryTransformId, getSLOTransformId } from '../../../../common/constants';
|
||||
import { useFetchSloHealth } from '../../../hooks/use_fetch_slo_health';
|
||||
import { useKibana } from '../../../utils/kibana_react';
|
||||
|
||||
export function SloHealthCallout({ slo }: { slo: SLOWithSummaryResponse }) {
|
||||
const { http } = useKibana().services;
|
||||
const { isLoading, isError, data } = useFetchSloHealth({ list: [slo] });
|
||||
|
||||
if (isLoading || isError || data === undefined || data?.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const health = data[0].health;
|
||||
if (health.overall === 'healthy') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const count = health.rollup === 'unhealthy' && health.summary === 'unhealthy' ? 2 : 1;
|
||||
|
||||
return (
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
iconType="warning"
|
||||
title={i18n.translate('xpack.slo.sloDetails.healthCallout.title', {
|
||||
defaultMessage: 'This SLO has issues with its transforms',
|
||||
})}
|
||||
>
|
||||
<EuiFlexGroup direction="column" alignItems="flexStart">
|
||||
<EuiFlexItem>
|
||||
<FormattedMessage
|
||||
id="xpack.slo.sloDetails.healthCallout.description"
|
||||
defaultMessage="The following {count, plural, one {transform is} other {transforms are}
|
||||
} in an unhealthy state:"
|
||||
values={{ count }}
|
||||
/>
|
||||
<ul>
|
||||
{health.rollup === 'unhealthy' && (
|
||||
<li>
|
||||
{getSLOTransformId(slo.id, slo.revision)}
|
||||
<EuiCopy textToCopy={getSLOTransformId(slo.id, slo.revision)}>
|
||||
{(copy) => (
|
||||
<EuiButtonIcon
|
||||
data-test-subj="sloSloHealthCalloutCopyButton"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.slo.sloDetails.healthCallout.copyToClipboard',
|
||||
{ defaultMessage: 'Copy to clipboard' }
|
||||
)}
|
||||
color="text"
|
||||
iconType="copy"
|
||||
onClick={copy}
|
||||
/>
|
||||
)}
|
||||
</EuiCopy>
|
||||
</li>
|
||||
)}
|
||||
{health.summary === 'unhealthy' && (
|
||||
<li>
|
||||
{getSLOSummaryTransformId(slo.id, slo.revision)}
|
||||
<EuiCopy textToCopy={getSLOSummaryTransformId(slo.id, slo.revision)}>
|
||||
{(copy) => (
|
||||
<EuiButtonIcon
|
||||
data-test-subj="sloSloHealthCalloutCopyButton"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.slo.sloDetails.healthCallout.copyToClipboard',
|
||||
{ defaultMessage: 'Copy to clipboard' }
|
||||
)}
|
||||
color="text"
|
||||
iconType="copy"
|
||||
onClick={copy}
|
||||
/>
|
||||
)}
|
||||
</EuiCopy>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
data-test-subj="sloSloHealthCalloutInspectTransformButton"
|
||||
color="danger"
|
||||
fill
|
||||
href={http?.basePath.prepend('/app/management/data/transform')}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.slo.sloDetails.healthCallout.buttonTransformLabel"
|
||||
defaultMessage="Inspect transform"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiCallOut>
|
||||
);
|
||||
}
|
|
@ -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 {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiButtonIcon,
|
||||
EuiCallOut,
|
||||
EuiCopy,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
|
||||
import React, { useState } from 'react';
|
||||
import { getSLOSummaryTransformId, getSLOTransformId } from '../../../../../common/constants';
|
||||
import { useFetchSloHealth } from '../../../../hooks/use_fetch_slo_health';
|
||||
import { useKibana } from '../../../../utils/kibana_react';
|
||||
|
||||
const CALLOUT_SESSION_STORAGE_KEY = 'slo_health_callout_hidden';
|
||||
|
||||
export function HealthCallout({ sloList }: { sloList: SLOWithSummaryResponse[] }) {
|
||||
const { http } = useKibana().services;
|
||||
const { isLoading, isError, data: results } = useFetchSloHealth({ list: sloList });
|
||||
const [showCallOut, setShowCallOut] = useState(
|
||||
!sessionStorage.getItem(CALLOUT_SESSION_STORAGE_KEY)
|
||||
);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
if (!showCallOut) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading || isError || results === undefined || results?.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unhealthySloList = results.filter((result) => result.health.overall === 'unhealthy');
|
||||
if (unhealthySloList.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unhealthyRollupTransforms = results.filter(
|
||||
(result) => result.health.rollup === 'unhealthy'
|
||||
);
|
||||
const unhealthySummaryTransforms = results.filter(
|
||||
(result) => result.health.summary === 'unhealthy'
|
||||
);
|
||||
|
||||
const dismiss = () => {
|
||||
setShowCallOut(false);
|
||||
sessionStorage.setItem('slo_health_callout_hidden', 'true');
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
iconType={isOpen ? 'arrowDown' : 'arrowRight'}
|
||||
size="s"
|
||||
onClick={(e) => {
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.slo.sloList.healthCallout.title"
|
||||
defaultMessage="Transform error detected"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isOpen && (
|
||||
<EuiFlexGroup
|
||||
direction="column"
|
||||
alignItems="flexStart"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<FormattedMessage
|
||||
id="xpack.slo.sloList.healthCallout.description"
|
||||
defaultMessage="The following {count, plural, one {transform is} other {transforms are}
|
||||
} in an unhealthy state:"
|
||||
values={{
|
||||
count: unhealthyRollupTransforms.length + unhealthySummaryTransforms.length,
|
||||
}}
|
||||
/>
|
||||
<ul>
|
||||
{unhealthyRollupTransforms.map((result) => (
|
||||
<li>
|
||||
{getSLOTransformId(result.sloId, result.sloRevision)}
|
||||
<EuiCopy textToCopy={getSLOTransformId(result.sloId, result.sloRevision)}>
|
||||
{(copy) => (
|
||||
<EuiButtonIcon
|
||||
data-test-subj="sloHealthCalloutCopyButton"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.slo.sloList.healthCallout.copyToClipboard',
|
||||
{ defaultMessage: 'Copy to clipboard' }
|
||||
)}
|
||||
color="text"
|
||||
iconType="copy"
|
||||
onClick={copy}
|
||||
/>
|
||||
)}
|
||||
</EuiCopy>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{unhealthySummaryTransforms.map((result) => (
|
||||
<li>
|
||||
{getSLOSummaryTransformId(result.sloId, result.sloRevision)}
|
||||
<EuiCopy textToCopy={getSLOSummaryTransformId(result.sloId, result.sloRevision)}>
|
||||
{(copy) => (
|
||||
<EuiButtonIcon
|
||||
data-test-subj="sloHealthCalloutCopyButton"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.slo.sloList.healthCallout.copyToClipboard',
|
||||
{ defaultMessage: 'Copy to clipboard' }
|
||||
)}
|
||||
color="text"
|
||||
iconType="copy"
|
||||
onClick={copy}
|
||||
/>
|
||||
)}
|
||||
</EuiCopy>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexGroup direction="row">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
data-test-subj="sloHealthCalloutInspectTransformButton"
|
||||
color="danger"
|
||||
size="s"
|
||||
fill
|
||||
href={http?.basePath.prepend('/app/management/data/transform')}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.slo.sloList.healthCallout.buttonTransformLabel"
|
||||
defaultMessage="Inspect transform"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="sloHealthCalloutDimissButton"
|
||||
color="text"
|
||||
size="s"
|
||||
onClick={dismiss}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.slo.sloList.healthCallout.buttonDimissLabel"
|
||||
defaultMessage="Dismiss"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
</EuiCallOut>
|
||||
);
|
||||
}
|
|
@ -7,17 +7,18 @@
|
|||
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiTablePagination } from '@elastic/eui';
|
||||
import { useIsMutating } from '@tanstack/react-query';
|
||||
import React, { useEffect } from 'react';
|
||||
import dedent from 'dedent';
|
||||
import { groupBy as _groupBy, mapValues } from 'lodash';
|
||||
import { useKibana } from '../../../utils/kibana_react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFetchSloList } from '../../../hooks/use_fetch_slo_list';
|
||||
import { useKibana } from '../../../utils/kibana_react';
|
||||
import { useUrlSearchState } from '../hooks/use_url_search_state';
|
||||
import { GroupView } from './grouped_slos/group_view';
|
||||
import { SlosView } from './slos_view';
|
||||
import { ToggleSLOView } from './toggle_slo_view';
|
||||
import { GroupView } from './grouped_slos/group_view';
|
||||
|
||||
export function SloList() {
|
||||
const { observabilityAIAssistant } = useKibana().services;
|
||||
const { state, onStateChange } = useUrlSearchState();
|
||||
const { view, page, perPage, kqlQuery, filters, tagsFilter, statusFilter, groupBy } = state;
|
||||
|
||||
|
@ -37,8 +38,6 @@ export function SloList() {
|
|||
sortDirection: state.sort.direction,
|
||||
lastRefresh: state.lastRefresh,
|
||||
});
|
||||
|
||||
const { observabilityAIAssistant } = useKibana().services;
|
||||
const { results = [], total = 0 } = sloList ?? {};
|
||||
|
||||
const isDeletingSlo = Boolean(useIsMutating(['deleteSlo']));
|
||||
|
|
|
@ -5,11 +5,12 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiFlexItem } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
|
||||
import React from 'react';
|
||||
import { SloListCardView } from './card_view/slos_card_view';
|
||||
import { SloListCompactView } from './compact_view/slo_list_compact_view';
|
||||
import { HealthCallout } from './health_callout/health_callout';
|
||||
import { SloListEmpty } from './slo_list_empty';
|
||||
import { SloListError } from './slo_list_error';
|
||||
import { SloListView } from './slo_list_view/slo_list_view';
|
||||
|
@ -33,23 +34,38 @@ export function SlosView({ sloList, loading, error, sloView }: Props) {
|
|||
|
||||
if (sloView === 'cardView') {
|
||||
return (
|
||||
<EuiFlexItem>
|
||||
<SloListCardView sloList={sloList} loading={loading} error={error} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem>
|
||||
<HealthCallout sloList={sloList} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<SloListCardView sloList={sloList} loading={loading} error={error} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (sloView === 'compactView') {
|
||||
return (
|
||||
<EuiFlexItem>
|
||||
<SloListCompactView sloList={sloList} loading={loading} error={error} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem>
|
||||
<HealthCallout sloList={sloList} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<SloListCompactView sloList={sloList} loading={loading} error={error} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFlexItem>
|
||||
<SloListView sloList={sloList} loading={loading} error={error} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem>
|
||||
<HealthCallout sloList={sloList} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<SloListView sloList={sloList} loading={loading} error={error} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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 { healthStatusSchema, stateSchema } from '@kbn/slo-schema';
|
||||
import * as t from 'io-ts';
|
||||
|
||||
type HealthStatus = t.OutputOf<typeof healthStatusSchema>;
|
||||
type State = t.OutputOf<typeof stateSchema>;
|
||||
|
||||
export type { HealthStatus, State };
|
|
@ -17,6 +17,7 @@ import {
|
|||
findSLOParamsSchema,
|
||||
getPreviewDataParamsSchema,
|
||||
getSLOBurnRatesParamsSchema,
|
||||
fetchSLOHealthParamsSchema,
|
||||
getSLOInstancesParamsSchema,
|
||||
getSLOParamsSchema,
|
||||
manageSLOParamsSchema,
|
||||
|
@ -24,7 +25,6 @@ import {
|
|||
resetSLOParamsSchema,
|
||||
updateSLOParamsSchema,
|
||||
} from '@kbn/slo-schema';
|
||||
import { GetSLOSuggestions } from '../../services/get_slo_suggestions';
|
||||
import type { IndicatorTypes } from '../../domain/models';
|
||||
import {
|
||||
CreateSLO,
|
||||
|
@ -36,6 +36,7 @@ import {
|
|||
FindSLO,
|
||||
FindSLOGroups,
|
||||
GetSLO,
|
||||
GetSLOHealth,
|
||||
KibanaSavedObjectsSLORepository,
|
||||
UpdateSLO,
|
||||
} from '../../services';
|
||||
|
@ -45,6 +46,7 @@ import { getBurnRates } from '../../services/get_burn_rates';
|
|||
import { getGlobalDiagnosis } from '../../services/get_diagnosis';
|
||||
import { GetPreviewData } from '../../services/get_preview_data';
|
||||
import { GetSLOInstances } from '../../services/get_slo_instances';
|
||||
import { GetSLOSuggestions } from '../../services/get_slo_suggestions';
|
||||
import { DefaultHistoricalSummaryClient } from '../../services/historical_summary_client';
|
||||
import { ManageSLO } from '../../services/manage_slo';
|
||||
import { ResetSLO } from '../../services/reset_slo';
|
||||
|
@ -558,6 +560,26 @@ const getDiagnosisRoute = createSloServerRoute({
|
|||
},
|
||||
});
|
||||
|
||||
const fetchSloHealthRoute = createSloServerRoute({
|
||||
endpoint: 'POST /internal/observability/slos/_health',
|
||||
options: {
|
||||
tags: ['access:slo_read'],
|
||||
access: 'internal',
|
||||
},
|
||||
params: fetchSLOHealthParamsSchema,
|
||||
handler: async ({ context, params, logger }) => {
|
||||
await assertPlatinumLicense(context);
|
||||
|
||||
const soClient = (await context.core).savedObjects.client;
|
||||
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
|
||||
const repository = new KibanaSavedObjectsSLORepository(soClient, logger);
|
||||
|
||||
const getSLOHealth = new GetSLOHealth(esClient, repository);
|
||||
|
||||
return await getSLOHealth.execute(params.body);
|
||||
},
|
||||
});
|
||||
|
||||
const getSloBurnRates = createSloServerRoute({
|
||||
endpoint: 'POST /internal/observability/slos/{id}/_burn_rates',
|
||||
options: {
|
||||
|
@ -639,6 +661,7 @@ const putSloSettings = createSloServerRoute({
|
|||
});
|
||||
|
||||
export const sloRouteRepository = {
|
||||
...fetchSloHealthRoute,
|
||||
...getSloSettingsRoute,
|
||||
...putSloSettings,
|
||||
...createSLORoute,
|
||||
|
|
|
@ -182,6 +182,7 @@ Array [
|
|||
"goodEvents": 0,
|
||||
"isTempDoc": true,
|
||||
"kibanaUrl": "http://myhost.com/mock-server-basepath",
|
||||
"latestSliTimestamp": null,
|
||||
"service": Object {
|
||||
"environment": "irrelevant",
|
||||
"name": "irrelevant",
|
||||
|
@ -222,6 +223,7 @@ Array [
|
|||
"spaceId": "some-space",
|
||||
"status": "NO_DATA",
|
||||
"statusCode": 0,
|
||||
"summaryUpdatedAt": null,
|
||||
"totalEvents": 0,
|
||||
"transaction": Object {
|
||||
"name": "irrelevant",
|
||||
|
|
|
@ -469,6 +469,7 @@ exports[`ResetSLO resets all associated resources 11`] = `
|
|||
"goodEvents": 0,
|
||||
"isTempDoc": true,
|
||||
"kibanaUrl": "http://myhost.com/mock-server-basepath",
|
||||
"latestSliTimestamp": null,
|
||||
"service": Object {
|
||||
"environment": "irrelevant",
|
||||
"name": "irrelevant",
|
||||
|
@ -513,6 +514,7 @@ exports[`ResetSLO resets all associated resources 11`] = `
|
|||
"spaceId": "some-space",
|
||||
"status": "NO_DATA",
|
||||
"statusCode": 0,
|
||||
"summaryUpdatedAt": null,
|
||||
"totalEvents": 0,
|
||||
"transaction": Object {
|
||||
"name": "irrelevant",
|
||||
|
|
|
@ -41,13 +41,13 @@ Object {
|
|||
"sloId": "slo-one",
|
||||
"summary": Object {
|
||||
"errorBudget": Object {
|
||||
"consumed": 0.4,
|
||||
"initial": 0.02,
|
||||
"consumed": 0,
|
||||
"initial": 0.001,
|
||||
"isEstimated": false,
|
||||
"remaining": 0.6,
|
||||
"remaining": 1,
|
||||
},
|
||||
"sliValue": 0.9,
|
||||
"status": "HEALTHY",
|
||||
"sliValue": -1,
|
||||
"status": "NO_DATA",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
|
@ -56,13 +56,13 @@ Object {
|
|||
"sloId": "slo_two",
|
||||
"summary": Object {
|
||||
"errorBudget": Object {
|
||||
"consumed": 0.4,
|
||||
"initial": 0.02,
|
||||
"consumed": 0,
|
||||
"initial": 0.001,
|
||||
"isEstimated": false,
|
||||
"remaining": 0.6,
|
||||
"remaining": 1,
|
||||
},
|
||||
"sliValue": 0.9,
|
||||
"status": "HEALTHY",
|
||||
"sliValue": -1,
|
||||
"status": "NO_DATA",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
|
@ -71,13 +71,13 @@ Object {
|
|||
"sloId": "slo-three",
|
||||
"summary": Object {
|
||||
"errorBudget": Object {
|
||||
"consumed": 0.4,
|
||||
"initial": 0.02,
|
||||
"consumed": 0,
|
||||
"initial": 0.001,
|
||||
"isEstimated": false,
|
||||
"remaining": 0.6,
|
||||
"remaining": 1,
|
||||
},
|
||||
"sliValue": 0.9,
|
||||
"status": "HEALTHY",
|
||||
"sliValue": -1,
|
||||
"status": "NO_DATA",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
|
@ -86,13 +86,13 @@ Object {
|
|||
"sloId": "slo-five",
|
||||
"summary": Object {
|
||||
"errorBudget": Object {
|
||||
"consumed": 0.4,
|
||||
"initial": 0.02,
|
||||
"consumed": 0,
|
||||
"initial": 0.001,
|
||||
"isEstimated": false,
|
||||
"remaining": 0.6,
|
||||
"remaining": 1,
|
||||
},
|
||||
"sliValue": 0.9,
|
||||
"status": "HEALTHY",
|
||||
"sliValue": -1,
|
||||
"status": "NO_DATA",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
|
@ -101,13 +101,13 @@ Object {
|
|||
"sloId": "slo-four",
|
||||
"summary": Object {
|
||||
"errorBudget": Object {
|
||||
"consumed": 0.4,
|
||||
"initial": 0.02,
|
||||
"consumed": 0,
|
||||
"initial": 0.001,
|
||||
"isEstimated": false,
|
||||
"remaining": 0.6,
|
||||
"remaining": 1,
|
||||
},
|
||||
"sliValue": 0.9,
|
||||
"status": "HEALTHY",
|
||||
"sliValue": -1,
|
||||
"status": "NO_DATA",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -5,51 +5,21 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ALL_VALUE } from '@kbn/slo-schema';
|
||||
import { IBasePath } from '@kbn/core/server';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { SLODefinition } from '../../domain/models';
|
||||
import {
|
||||
createTempSummaryDocument,
|
||||
EsSummaryDocument,
|
||||
} from '../summary_transform_generator/helpers/create_temp_summary';
|
||||
|
||||
export const aSummaryDocument = ({
|
||||
id = uuidv4(),
|
||||
sliValue = 0.9,
|
||||
consumed = 0.4,
|
||||
isTempDoc = false,
|
||||
status = 'HEALTHY',
|
||||
} = {}) => {
|
||||
export const aSummaryDocument = (
|
||||
slo: SLODefinition,
|
||||
params: Partial<EsSummaryDocument> = {}
|
||||
): EsSummaryDocument => {
|
||||
return {
|
||||
goodEvents: 96,
|
||||
totalEvents: 100,
|
||||
errorBudgetEstimated: false,
|
||||
errorBudgetRemaining: 1 - consumed,
|
||||
errorBudgetConsumed: consumed,
|
||||
isTempDoc,
|
||||
service: {
|
||||
environment: null,
|
||||
name: null,
|
||||
},
|
||||
slo: {
|
||||
indicator: {
|
||||
type: 'sli.kql.custom',
|
||||
},
|
||||
timeWindow: {
|
||||
duration: '30d',
|
||||
type: 'rolling',
|
||||
},
|
||||
instanceId: ALL_VALUE,
|
||||
name: 'irrelevant',
|
||||
description: '',
|
||||
id,
|
||||
budgetingMethod: 'occurrences',
|
||||
revision: 1,
|
||||
tags: ['tag-one', 'tag-two', 'irrelevant'],
|
||||
},
|
||||
errorBudgetInitial: 0.02,
|
||||
transaction: {
|
||||
name: null,
|
||||
type: null,
|
||||
},
|
||||
sliValue,
|
||||
statusCode: 4,
|
||||
status,
|
||||
...createTempSummaryDocument(slo, 'default', { publicBaseUrl: '' } as IBasePath),
|
||||
...params,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,349 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ElasticsearchClient } from '@kbn/core/server';
|
||||
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
|
||||
import { ALL_VALUE } from '@kbn/slo-schema';
|
||||
import { getSLOSummaryTransformId, getSLOTransformId } from '../../common/constants';
|
||||
import { createSLO } from './fixtures/slo';
|
||||
import {
|
||||
aHitFromSummaryIndex,
|
||||
aHitFromTempSummaryIndex,
|
||||
aSummaryDocument,
|
||||
} from './fixtures/summary_search_document';
|
||||
import { GetSLOHealth } from './get_slo_health';
|
||||
import { createSLORepositoryMock } from './mocks';
|
||||
import { SLORepository } from './slo_repository';
|
||||
|
||||
describe('GetSLOHealth', () => {
|
||||
let mockRepository: jest.Mocked<SLORepository>;
|
||||
let mockEsClient: jest.Mocked<ElasticsearchClient>;
|
||||
let getSLOHealth: GetSLOHealth;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepository = createSLORepositoryMock();
|
||||
mockEsClient = elasticsearchServiceMock.createElasticsearchClient();
|
||||
getSLOHealth = new GetSLOHealth(mockEsClient, mockRepository);
|
||||
});
|
||||
|
||||
it('returns the health and state', async () => {
|
||||
const slo = createSLO({ id: '95ffb9af-1384-4d24-8e3f-345a03d7a439' });
|
||||
mockRepository.findAllByIds.mockResolvedValueOnce([slo]);
|
||||
mockEsClient.search.mockResolvedValue({
|
||||
took: 0,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
total: 2,
|
||||
successful: 2,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
total: {
|
||||
value: 1,
|
||||
relation: 'eq',
|
||||
},
|
||||
max_score: 1,
|
||||
hits: [
|
||||
aHitFromSummaryIndex(aSummaryDocument(slo)),
|
||||
aHitFromTempSummaryIndex(aSummaryDocument(slo, { isTempDoc: true })), // kept
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getSLOHealth.execute({
|
||||
list: [{ sloId: slo.id, sloInstanceId: ALL_VALUE }],
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"health": Object {
|
||||
"overall": "unhealthy",
|
||||
"rollup": "unhealthy",
|
||||
"summary": "unhealthy",
|
||||
},
|
||||
"sloId": "95ffb9af-1384-4d24-8e3f-345a03d7a439",
|
||||
"sloInstanceId": "*",
|
||||
"sloRevision": 1,
|
||||
"state": "no_data",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('handles inexistant sloId', async () => {
|
||||
mockRepository.findAllByIds.mockResolvedValueOnce([]);
|
||||
mockEsClient.search.mockResolvedValue({
|
||||
took: 0,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
total: 2,
|
||||
successful: 2,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
total: {
|
||||
value: 0,
|
||||
relation: 'eq',
|
||||
},
|
||||
max_score: 1,
|
||||
hits: [],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getSLOHealth.execute({
|
||||
list: [{ sloId: 'inexistant', sloInstanceId: ALL_VALUE }],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('computes health', () => {
|
||||
it('returns healthy when both transforms are healthy', async () => {
|
||||
const slo = createSLO({ id: '95ffb9af-1384-4d24-8e3f-345a03d7a439' });
|
||||
mockRepository.findAllByIds.mockResolvedValueOnce([slo]);
|
||||
mockEsClient.search.mockResolvedValue({
|
||||
took: 0,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
total: 2,
|
||||
successful: 2,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
total: {
|
||||
value: 1,
|
||||
relation: 'eq',
|
||||
},
|
||||
max_score: 1,
|
||||
hits: [aHitFromSummaryIndex(aSummaryDocument(slo))],
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
mockEsClient.transform.getTransformStats.mockResolvedValue({
|
||||
transforms: [
|
||||
{ id: getSLOTransformId(slo.id, slo.revision), health: { status: 'green' } },
|
||||
{ id: getSLOSummaryTransformId(slo.id, slo.revision), health: { status: 'green' } },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getSLOHealth.execute({
|
||||
list: [{ sloId: slo.id, sloInstanceId: ALL_VALUE }],
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"health": Object {
|
||||
"overall": "healthy",
|
||||
"rollup": "healthy",
|
||||
"summary": "healthy",
|
||||
},
|
||||
"sloId": "95ffb9af-1384-4d24-8e3f-345a03d7a439",
|
||||
"sloInstanceId": "*",
|
||||
"sloRevision": 1,
|
||||
"state": "no_data",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('returns unhealthy whenever one of the transform is unhealthy', async () => {
|
||||
const slo = createSLO({ id: '95ffb9af-1384-4d24-8e3f-345a03d7a439' });
|
||||
mockRepository.findAllByIds.mockResolvedValueOnce([slo]);
|
||||
mockEsClient.search.mockResolvedValue({
|
||||
took: 0,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
total: 2,
|
||||
successful: 2,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
total: {
|
||||
value: 1,
|
||||
relation: 'eq',
|
||||
},
|
||||
max_score: 1,
|
||||
hits: [aHitFromSummaryIndex(aSummaryDocument(slo))],
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
mockEsClient.transform.getTransformStats.mockResolvedValue({
|
||||
transforms: [
|
||||
{ id: getSLOTransformId(slo.id, slo.revision), health: { status: 'yellow' } },
|
||||
{ id: getSLOSummaryTransformId(slo.id, slo.revision), health: { status: 'green' } },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getSLOHealth.execute({
|
||||
list: [{ sloId: slo.id, sloInstanceId: ALL_VALUE }],
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"health": Object {
|
||||
"overall": "unhealthy",
|
||||
"rollup": "unhealthy",
|
||||
"summary": "healthy",
|
||||
},
|
||||
"sloId": "95ffb9af-1384-4d24-8e3f-345a03d7a439",
|
||||
"sloInstanceId": "*",
|
||||
"sloRevision": 1,
|
||||
"state": "no_data",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computes state', () => {
|
||||
it('returns stale when summaryUpdatedAt is 2 days old', async () => {
|
||||
const slo = createSLO({ id: '95ffb9af-1384-4d24-8e3f-345a03d7a439' });
|
||||
mockRepository.findAllByIds.mockResolvedValueOnce([slo]);
|
||||
mockEsClient.search.mockResolvedValue({
|
||||
took: 0,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
total: 2,
|
||||
successful: 2,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
total: {
|
||||
value: 1,
|
||||
relation: 'eq',
|
||||
},
|
||||
max_score: 1,
|
||||
hits: [
|
||||
aHitFromSummaryIndex(
|
||||
aSummaryDocument(slo, {
|
||||
summaryUpdatedAt: new Date(Date.now() - 49 * 60 * 60 * 1000).toISOString(),
|
||||
latestSliTimestamp: new Date(Date.now() - 60 * 60 * 60 * 1000).toISOString(),
|
||||
isTempDoc: false,
|
||||
})
|
||||
),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
mockEsClient.transform.getTransformStats.mockResolvedValue({
|
||||
transforms: [
|
||||
{ id: getSLOTransformId(slo.id, slo.revision), health: { status: 'green' } },
|
||||
{ id: getSLOSummaryTransformId(slo.id, slo.revision), health: { status: 'green' } },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getSLOHealth.execute({
|
||||
list: [{ sloId: slo.id, sloInstanceId: ALL_VALUE }],
|
||||
});
|
||||
|
||||
expect(result[0].state).toBe('stale');
|
||||
});
|
||||
|
||||
it("returns 'indexing' when diff(summaryUpdatedAt - latestSliTimestamp) >= 10min", async () => {
|
||||
const slo = createSLO({ id: '95ffb9af-1384-4d24-8e3f-345a03d7a439' });
|
||||
const now = Date.now();
|
||||
mockRepository.findAllByIds.mockResolvedValueOnce([slo]);
|
||||
mockEsClient.search.mockResolvedValue({
|
||||
took: 0,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
total: 2,
|
||||
successful: 2,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
total: {
|
||||
value: 1,
|
||||
relation: 'eq',
|
||||
},
|
||||
max_score: 1,
|
||||
hits: [
|
||||
aHitFromSummaryIndex(
|
||||
aSummaryDocument(slo, {
|
||||
summaryUpdatedAt: new Date(now).toISOString(),
|
||||
latestSliTimestamp: new Date(now - 10 * 60 * 1000).toISOString(), // 10min
|
||||
isTempDoc: false,
|
||||
})
|
||||
),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
mockEsClient.transform.getTransformStats.mockResolvedValue({
|
||||
transforms: [
|
||||
{ id: getSLOTransformId(slo.id, slo.revision), health: { status: 'green' } },
|
||||
{ id: getSLOSummaryTransformId(slo.id, slo.revision), health: { status: 'green' } },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getSLOHealth.execute({
|
||||
list: [{ sloId: slo.id, sloInstanceId: ALL_VALUE }],
|
||||
});
|
||||
|
||||
expect(result[0].state).toBe('indexing');
|
||||
});
|
||||
|
||||
it("returns 'running' when diff(summaryUpdatedAt - latestSliTimestamp) < 10min", async () => {
|
||||
const slo = createSLO({ id: '95ffb9af-1384-4d24-8e3f-345a03d7a439' });
|
||||
const now = Date.now();
|
||||
mockRepository.findAllByIds.mockResolvedValueOnce([slo]);
|
||||
mockEsClient.search.mockResolvedValue({
|
||||
took: 0,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
total: 2,
|
||||
successful: 2,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
total: {
|
||||
value: 1,
|
||||
relation: 'eq',
|
||||
},
|
||||
max_score: 1,
|
||||
hits: [
|
||||
aHitFromSummaryIndex(
|
||||
aSummaryDocument(slo, {
|
||||
summaryUpdatedAt: new Date(now).toISOString(),
|
||||
latestSliTimestamp: new Date(now - 9 * 60 * 1000 + 59 * 1000).toISOString(), // 9min59s
|
||||
isTempDoc: false,
|
||||
})
|
||||
),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
mockEsClient.transform.getTransformStats.mockResolvedValue({
|
||||
transforms: [
|
||||
{ id: getSLOTransformId(slo.id, slo.revision), health: { status: 'green' } },
|
||||
{ id: getSLOSummaryTransformId(slo.id, slo.revision), health: { status: 'green' } },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getSLOHealth.execute({
|
||||
list: [{ sloId: slo.id, sloInstanceId: ALL_VALUE }],
|
||||
});
|
||||
|
||||
expect(result[0].state).toBe('running');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 { TransformGetTransformStatsTransformStats } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { ElasticsearchClient } from '@kbn/core/server';
|
||||
import {
|
||||
FetchSLOHealthParams,
|
||||
FetchSLOHealthResponse,
|
||||
fetchSLOHealthResponseSchema,
|
||||
} from '@kbn/slo-schema';
|
||||
import { Dictionary, groupBy, keyBy } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
getSLOSummaryTransformId,
|
||||
getSLOTransformId,
|
||||
SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
|
||||
} from '../../common/constants';
|
||||
import { SLODefinition } from '../domain/models';
|
||||
import { HealthStatus, State } from '../domain/models/health';
|
||||
import { SLORepository } from './slo_repository';
|
||||
import { EsSummaryDocument } from './summary_transform_generator/helpers/create_temp_summary';
|
||||
|
||||
const LAG_THRESHOLD_MINUTES = 10;
|
||||
const STALE_THRESHOLD_MINUTES = 2 * 24 * 60;
|
||||
|
||||
export class GetSLOHealth {
|
||||
constructor(private esClient: ElasticsearchClient, private repository: SLORepository) {}
|
||||
|
||||
public async execute(params: FetchSLOHealthParams): Promise<FetchSLOHealthResponse> {
|
||||
const sloIds = params.list.map(({ sloId }) => sloId);
|
||||
const sloList = await this.repository.findAllByIds(sloIds);
|
||||
const sloById = keyBy(sloList, 'id');
|
||||
|
||||
const filteredList = params.list
|
||||
.filter((item) => !!sloById[item.sloId])
|
||||
.map((item) => ({
|
||||
sloId: item.sloId,
|
||||
sloInstanceId: item.sloInstanceId,
|
||||
sloRevision: sloById[item.sloId].revision,
|
||||
}));
|
||||
|
||||
const transformStatsById = await this.getTransformStats(sloList);
|
||||
const summaryDocsById = await this.getSummaryDocsById(filteredList);
|
||||
|
||||
const results = filteredList.map((item) => {
|
||||
const health = computeHealth(transformStatsById, item);
|
||||
const state = computeState(summaryDocsById, item);
|
||||
|
||||
return {
|
||||
sloId: item.sloId,
|
||||
sloInstanceId: item.sloInstanceId,
|
||||
sloRevision: item.sloRevision,
|
||||
state,
|
||||
health,
|
||||
};
|
||||
});
|
||||
|
||||
return fetchSLOHealthResponseSchema.encode(results);
|
||||
}
|
||||
|
||||
private async getSummaryDocsById(
|
||||
filteredList: Array<{ sloId: string; sloInstanceId: string; sloRevision: number }>
|
||||
) {
|
||||
const summaryDocs = await this.esClient.search<EsSummaryDocument>({
|
||||
index: SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
|
||||
query: {
|
||||
bool: {
|
||||
should: filteredList.map((item) => ({
|
||||
bool: {
|
||||
must: [
|
||||
{ term: { 'slo.id': item.sloId } },
|
||||
{ term: { 'slo.instanceId': item.sloInstanceId } },
|
||||
],
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const summaryDocsById = groupBy(
|
||||
summaryDocs.hits.hits.map((hit) => hit._source!),
|
||||
(doc: EsSummaryDocument) => buildSummaryKey(doc.slo.id, doc.slo.instanceId)
|
||||
);
|
||||
return summaryDocsById;
|
||||
}
|
||||
|
||||
private async getTransformStats(
|
||||
sloList: SLODefinition[]
|
||||
): Promise<Dictionary<TransformGetTransformStatsTransformStats>> {
|
||||
const transformStats = await this.esClient.transform.getTransformStats(
|
||||
{
|
||||
transform_id: sloList
|
||||
.map((slo: SLODefinition) => [
|
||||
getSLOTransformId(slo.id, slo.revision),
|
||||
getSLOSummaryTransformId(slo.id, slo.revision),
|
||||
])
|
||||
.flat(),
|
||||
allow_no_match: true,
|
||||
size: sloList.length * 2,
|
||||
},
|
||||
{ ignore: [404] }
|
||||
);
|
||||
|
||||
return keyBy(transformStats.transforms, (transform) => transform.id);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSummaryKey(id: string, instanceId: string) {
|
||||
return id + '|' + instanceId;
|
||||
}
|
||||
|
||||
function computeState(
|
||||
summaryDocsById: Dictionary<EsSummaryDocument[]>,
|
||||
item: { sloId: string; sloInstanceId: string; sloRevision: number }
|
||||
): State {
|
||||
const sloSummaryDocs = summaryDocsById[buildSummaryKey(item.sloId, item.sloInstanceId)];
|
||||
|
||||
let state: State = 'no_data';
|
||||
if (!sloSummaryDocs) {
|
||||
return state;
|
||||
}
|
||||
const hasOnlyTempSummaryDoc = sloSummaryDocs.every((doc) => doc.isTempDoc); // only temporary documents mean the summary transform did not run yet
|
||||
const sloSummarydoc = sloSummaryDocs.find((doc) => !doc.isTempDoc);
|
||||
const latestSliTimestamp = sloSummarydoc?.latestSliTimestamp;
|
||||
const summaryUpdatedAt = sloSummarydoc?.summaryUpdatedAt;
|
||||
|
||||
if (hasOnlyTempSummaryDoc) {
|
||||
state = 'no_data';
|
||||
} else if (summaryUpdatedAt && latestSliTimestamp) {
|
||||
const summaryLag = moment().diff(new Date(summaryUpdatedAt), 'minute');
|
||||
const indexingLag = moment(summaryUpdatedAt).diff(new Date(latestSliTimestamp), 'minute');
|
||||
|
||||
// When the summaryUpdatedAt is greater than STALE_THRESHOLD_MINUTES minutes, the SLO is considered stale since no new data triggered a summary document update.
|
||||
// When the difference between the summaryUpdatedAt and the latestSliTimestamp is
|
||||
// - Below LAG_THRESHOLD_MINUTES minutes, the SLO has cought up with the sli data, and is running correctly
|
||||
// - Above LAG_THRESHOLD_MINUTES minutes, the SLO is indexing
|
||||
if (summaryLag > STALE_THRESHOLD_MINUTES) {
|
||||
state = 'stale';
|
||||
} else {
|
||||
state = indexingLag >= LAG_THRESHOLD_MINUTES ? 'indexing' : 'running';
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function getTransformHealth(
|
||||
transformStat?: TransformGetTransformStatsTransformStats
|
||||
): HealthStatus {
|
||||
return transformStat?.health?.status?.toLowerCase() === 'green' ? 'healthy' : 'unhealthy';
|
||||
}
|
||||
|
||||
function computeHealth(
|
||||
transformStatsById: Dictionary<TransformGetTransformStatsTransformStats>,
|
||||
item: { sloId: string; sloInstanceId: string; sloRevision: number }
|
||||
): { overall: HealthStatus; rollup: HealthStatus; summary: HealthStatus } {
|
||||
const rollup = getTransformHealth(
|
||||
transformStatsById[getSLOTransformId(item.sloId, item.sloRevision)]
|
||||
);
|
||||
const summary = getTransformHealth(
|
||||
transformStatsById[getSLOSummaryTransformId(item.sloId, item.sloRevision)]
|
||||
);
|
||||
|
||||
const overall: HealthStatus =
|
||||
rollup === 'healthy' && summary === 'healthy' ? 'healthy' : 'unhealthy';
|
||||
|
||||
return { overall, rollup, summary };
|
||||
}
|
|
@ -22,3 +22,4 @@ export * from './update_slo';
|
|||
export * from './summary_client';
|
||||
export * from './get_slo_instances';
|
||||
export * from './find_slo_groups';
|
||||
export * from './get_slo_health';
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import { ElasticsearchClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
import { Pagination } from '@kbn/slo-schema/src/models/pagination';
|
||||
import { createSLO } from './fixtures/slo';
|
||||
import {
|
||||
aHitFromSummaryIndex,
|
||||
aHitFromTempSummaryIndex,
|
||||
|
@ -43,7 +44,7 @@ describe('Summary Search Client', () => {
|
|||
esClientMock,
|
||||
soClientMock,
|
||||
loggerMock.create(),
|
||||
'some-space'
|
||||
'default'
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -79,11 +80,11 @@ describe('Summary Search Client', () => {
|
|||
});
|
||||
|
||||
it('returns the summary documents without duplicate temporary summary documents', async () => {
|
||||
const SLO_ID1 = 'slo-one';
|
||||
const SLO_ID2 = 'slo_two';
|
||||
const SLO_ID3 = 'slo-three';
|
||||
const SLO_ID4 = 'slo-four';
|
||||
const SLO_ID5 = 'slo-five';
|
||||
const SLO_ID1 = createSLO({ id: 'slo-one' });
|
||||
const SLO_ID2 = createSLO({ id: 'slo_two' });
|
||||
const SLO_ID3 = createSLO({ id: 'slo-three' });
|
||||
const SLO_ID4 = createSLO({ id: 'slo-four' });
|
||||
const SLO_ID5 = createSLO({ id: 'slo-five' });
|
||||
|
||||
esClientMock.search.mockResolvedValue({
|
||||
took: 0,
|
||||
|
@ -101,14 +102,14 @@ describe('Summary Search Client', () => {
|
|||
},
|
||||
max_score: 1,
|
||||
hits: [
|
||||
aHitFromSummaryIndex(aSummaryDocument({ id: SLO_ID1 })),
|
||||
aHitFromSummaryIndex(aSummaryDocument({ id: SLO_ID2 })),
|
||||
aHitFromSummaryIndex(aSummaryDocument({ id: SLO_ID3 })),
|
||||
aHitFromSummaryIndex(aSummaryDocument({ id: SLO_ID5 })), // no related temp doc
|
||||
aHitFromTempSummaryIndex(aSummaryDocument({ id: SLO_ID1, isTempDoc: true })), // removed as dup
|
||||
aHitFromTempSummaryIndex(aSummaryDocument({ id: SLO_ID2, isTempDoc: true })), // removed as dup
|
||||
aHitFromTempSummaryIndex(aSummaryDocument({ id: SLO_ID3, isTempDoc: true })), // removed as dup
|
||||
aHitFromTempSummaryIndex(aSummaryDocument({ id: SLO_ID4, isTempDoc: true })), // kept
|
||||
aHitFromSummaryIndex(aSummaryDocument(SLO_ID1, { isTempDoc: false })),
|
||||
aHitFromSummaryIndex(aSummaryDocument(SLO_ID2, { isTempDoc: false })),
|
||||
aHitFromSummaryIndex(aSummaryDocument(SLO_ID3, { isTempDoc: false })),
|
||||
aHitFromSummaryIndex(aSummaryDocument(SLO_ID5, { isTempDoc: false })), // no related temp doc
|
||||
aHitFromTempSummaryIndex(aSummaryDocument(SLO_ID1, { isTempDoc: true })), // removed as dup
|
||||
aHitFromTempSummaryIndex(aSummaryDocument(SLO_ID2, { isTempDoc: true })), // removed as dup
|
||||
aHitFromTempSummaryIndex(aSummaryDocument(SLO_ID3, { isTempDoc: true })), // removed as dup
|
||||
aHitFromTempSummaryIndex(aSummaryDocument(SLO_ID4, { isTempDoc: true })), // kept
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
|
@ -48,6 +48,8 @@ export interface EsSummaryDocument {
|
|||
isTempDoc: boolean;
|
||||
spaceId: string;
|
||||
kibanaUrl?: string; // >= 8.14
|
||||
summaryUpdatedAt: string | null;
|
||||
latestSliTimestamp: string | null;
|
||||
}
|
||||
|
||||
export function createTempSummaryDocument(
|
||||
|
@ -102,6 +104,8 @@ export function createTempSummaryDocument(
|
|||
isTempDoc: true,
|
||||
spaceId,
|
||||
kibanaUrl: basePath.publicBaseUrl ?? '', // added in 8.14, i.e. might be undefined
|
||||
summaryUpdatedAt: null,
|
||||
latestSliTimestamp: null,
|
||||
};
|
||||
|
||||
return doc;
|
||||
|
|
|
@ -62,6 +62,8 @@ describe('FromRemoteSummaryDocToSlo', () => {
|
|||
status: 'NO_DATA',
|
||||
isTempDoc: true,
|
||||
spaceId: 'irrelevant',
|
||||
summaryUpdatedAt: null,
|
||||
latestSliTimestamp: null,
|
||||
},
|
||||
loggerMock
|
||||
);
|
||||
|
@ -123,6 +125,8 @@ describe('FromRemoteSummaryDocToSlo', () => {
|
|||
isTempDoc: true,
|
||||
spaceId: 'irrelevant',
|
||||
kibanaUrl: 'http://kibana.com/base-path', // added in 8.14
|
||||
summaryUpdatedAt: null,
|
||||
latestSliTimestamp: null,
|
||||
},
|
||||
loggerMock
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue