feat(slo): health status (#181351)

This commit is contained in:
Kevin Delemme 2024-04-30 10:26:30 -04:00 committed by GitHub
parent b8d8c737e6
commit 06d32af345
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1049 additions and 97 deletions

View file

@ -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 };

View file

@ -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';

View 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 };

View file

@ -11,3 +11,4 @@ export * from './indicators';
export * from './time_window';
export * from './slo';
export * from './settings';
export * from './health';

View file

@ -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,

View file

@ -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,
};
}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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']));

View file

@ -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>
);
}

View file

@ -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 };

View file

@ -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,

View file

@ -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",

View file

@ -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",

View file

@ -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",
},
},
],

View file

@ -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,
};
};

View file

@ -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');
});
});
});

View file

@ -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 };
}

View file

@ -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';

View file

@ -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
],
},
});

View file

@ -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;

View file

@ -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
);