feat(slo): Introduce group by (#163008)

This commit is contained in:
Kevin Delemme 2023-08-03 14:15:50 -04:00 committed by GitHub
parent 1a3aefe6ec
commit 7d3fe32976
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 979 additions and 315 deletions

View file

@ -7,6 +7,7 @@
import * as t from 'io-ts';
import {
allOrAnyString,
apmTransactionDurationIndicatorSchema,
apmTransactionErrorRateIndicatorSchema,
budgetingMethodSchema,
@ -39,7 +40,12 @@ const createSLOParamsSchema = t.type({
budgetingMethod: budgetingMethodSchema,
objective: objectiveSchema,
}),
t.partial({ id: sloIdSchema, settings: optionalSettingsSchema, tags: tagsSchema }),
t.partial({
id: sloIdSchema,
settings: optionalSettingsSchema,
tags: tagsSchema,
groupBy: allOrAnyString,
}),
]),
});
@ -61,12 +67,6 @@ const deleteSLOParamsSchema = t.type({
}),
});
const getSLOParamsSchema = t.type({
path: t.type({
id: sloIdSchema,
}),
});
const sortDirectionSchema = t.union([t.literal('asc'), t.literal('desc')]);
const sortBySchema = t.union([
t.literal('error_budget_consumed'),
@ -85,27 +85,47 @@ const findSLOParamsSchema = t.partial({
}),
});
const sloResponseSchema = t.type({
id: sloIdSchema,
name: t.string,
description: t.string,
indicator: indicatorSchema,
timeWindow: timeWindowSchema,
budgetingMethod: budgetingMethodSchema,
objective: objectiveSchema,
revision: t.number,
settings: settingsSchema,
enabled: t.boolean,
tags: tagsSchema,
createdAt: dateType,
updatedAt: dateType,
});
const sloResponseSchema = t.intersection([
t.type({
id: sloIdSchema,
name: t.string,
description: t.string,
indicator: indicatorSchema,
timeWindow: timeWindowSchema,
budgetingMethod: budgetingMethodSchema,
objective: objectiveSchema,
revision: t.number,
settings: settingsSchema,
enabled: t.boolean,
tags: tagsSchema,
groupBy: allOrAnyString,
createdAt: dateType,
updatedAt: dateType,
}),
t.partial({
instanceId: allOrAnyString,
}),
]);
const sloWithSummaryResponseSchema = t.intersection([
sloResponseSchema,
t.type({ summary: summarySchema }),
]);
const getSLOQuerySchema = t.partial({
query: t.partial({
instanceId: allOrAnyString,
}),
});
const getSLOParamsSchema = t.intersection([
t.type({
path: t.type({
id: sloIdSchema,
}),
}),
getSLOQuerySchema,
]);
const getSLOResponseSchema = sloWithSummaryResponseSchema;
const updateSLOParamsSchema = t.type({
@ -121,6 +141,7 @@ const updateSLOParamsSchema = t.type({
objective: objectiveSchema,
settings: optionalSettingsSchema,
tags: tagsSchema,
groupBy: allOrAnyString,
}),
});
@ -171,6 +192,15 @@ const getSLOBurnRatesParamsSchema = t.type({
}),
});
const getSLOInstancesParamsSchema = t.type({
path: t.type({ id: t.string }),
});
const getSLOInstancesResponseSchema = t.type({
groupBy: t.string,
instances: t.array(t.string),
});
type SLOResponse = t.OutputOf<typeof sloResponseSchema>;
type SLOWithSummaryResponse = t.OutputOf<typeof sloWithSummaryResponseSchema>;
@ -178,6 +208,7 @@ type CreateSLOInput = t.OutputOf<typeof createSLOParamsSchema.props.body>; // Ra
type CreateSLOParams = t.TypeOf<typeof createSLOParamsSchema.props.body>; // Parsed payload used by the backend
type CreateSLOResponse = t.TypeOf<typeof createSLOResponseSchema>; // Raw response sent to the frontend
type GetSLOParams = t.TypeOf<typeof getSLOQuerySchema.props.query>;
type GetSLOResponse = t.OutputOf<typeof getSLOResponseSchema>;
type ManageSLOParams = t.TypeOf<typeof manageSLOParamsSchema.props.path>;
@ -196,6 +227,8 @@ type HistoricalSummaryResponse = t.OutputOf<typeof historicalSummarySchema>;
type GetPreviewDataParams = t.TypeOf<typeof getPreviewDataParamsSchema.props.body>;
type GetPreviewDataResponse = t.OutputOf<typeof getPreviewDataResponseSchema>;
type GetSLOInstancesResponse = t.OutputOf<typeof getSLOInstancesResponseSchema>;
type GetSLOBurnRatesResponse = t.OutputOf<typeof getSLOBurnRatesResponseSchema>;
type BudgetingMethod = t.OutputOf<typeof budgetingMethodSchema>;
type TimeWindow = t.OutputOf<typeof timeWindowTypeSchema>;
@ -226,6 +259,8 @@ export {
updateSLOResponseSchema,
getSLOBurnRatesParamsSchema,
getSLOBurnRatesResponseSchema,
getSLOInstancesParamsSchema,
getSLOInstancesResponseSchema,
};
export type {
BudgetingMethod,
@ -236,6 +271,7 @@ export type {
FindSLOResponse,
GetPreviewDataParams,
GetPreviewDataResponse,
GetSLOParams,
GetSLOResponse,
FetchHistoricalSummaryParams,
FetchHistoricalSummaryResponse,
@ -249,6 +285,7 @@ export type {
APMTransactionDurationIndicator,
APMTransactionErrorRateIndicator,
GetSLOBurnRatesResponse,
GetSLOInstancesResponse,
IndicatorType,
Indicator,
MetricCustomIndicator,

View file

@ -6,7 +6,7 @@
*/
import * as t from 'io-ts';
import { dateType, summarySchema } from './common';
import { allOrAnyString, dateType, summarySchema } from './common';
import { durationType } from './duration';
import { indicatorSchema } from './indicators';
import { timeWindowSchema } from './time_window';
@ -32,9 +32,7 @@ const settingsSchema = t.type({
});
const optionalSettingsSchema = t.partial({ ...settingsSchema.props });
const tagsSchema = t.array(t.string);
const sloIdSchema = t.string;
const sloSchema = t.type({
@ -51,6 +49,7 @@ const sloSchema = t.type({
tags: tagsSchema,
createdAt: dateType,
updatedAt: dateType,
groupBy: allOrAnyString,
});
const sloWithSummarySchema = t.intersection([sloSchema, t.type({ summary: summarySchema })]);

View file

@ -266,6 +266,15 @@
},
{
"$ref": "#/components/parameters/slo_id"
},
{
"name": "instanceId",
"in": "query",
"description": "the specific instanceId used by the summary calculation",
"schema": {
"type": "string"
},
"example": "host-abcde"
}
],
"responses": {
@ -1368,6 +1377,16 @@
"type": "boolean",
"example": true
},
"groupBy": {
"description": "optional group by field to use to generate an SLO per distinct value",
"type": "string",
"example": "some.field"
},
"instanceId": {
"description": "the value derived from the groupBy field, if present, otherwise '*'",
"type": "string",
"example": "host-abcde"
},
"createdAt": {
"description": "The creation date",
"type": "string",
@ -1553,6 +1572,11 @@
},
"settings": {
"$ref": "#/components/schemas/settings"
},
"groupBy": {
"description": "optional group by field to use to generate an SLO per distinct value",
"type": "string",
"example": "some.field"
}
}
},

View file

@ -165,6 +165,12 @@ paths:
- $ref: '#/components/parameters/kbn_xsrf'
- $ref: '#/components/parameters/space_id'
- $ref: '#/components/parameters/slo_id'
- name: instanceId
in: query
description: the specific instanceId used by the summary calculation
schema:
type: string
example: host-abcde
responses:
'200':
description: Successful request
@ -939,6 +945,14 @@ components:
description: Indicate if the SLO is enabled
type: boolean
example: true
groupBy:
description: optional group by field to use to generate an SLO per distinct value
type: string
example: some.field
instanceId:
description: the value derived from the groupBy field, if present, otherwise '*'
type: string
example: host-abcde
createdAt:
description: The creation date
type: string
@ -1072,6 +1086,10 @@ components:
$ref: '#/components/schemas/objective'
settings:
$ref: '#/components/schemas/settings'
groupBy:
description: optional group by field to use to generate an SLO per distinct value
type: string
example: some.field
create_slo_response:
title: Create SLO response
type: object

View file

@ -35,3 +35,7 @@ properties:
$ref: "objective.yaml"
settings:
$ref: "settings.yaml"
groupBy:
description: optional group by field to use to generate an SLO per distinct value
type: string
example: "some.field"

View file

@ -46,6 +46,14 @@ properties:
description: Indicate if the SLO is enabled
type: boolean
example: true
groupBy:
description: optional group by field to use to generate an SLO per distinct value
type: string
example: "some.field"
instanceId:
description: the value derived from the groupBy field, if present, otherwise '*'
type: string
example: 'host-abcde'
createdAt:
description: The creation date
type: string

View file

@ -10,6 +10,12 @@ get:
- $ref: ../components/headers/kbn_xsrf.yaml
- $ref: ../components/parameters/space_id.yaml
- $ref: ../components/parameters/slo_id.yaml
- name: instanceId
in: query
description: the specific instanceId used by the summary calculation
schema:
type: string
example: 'host-abcde'
responses:
'200':
description: Successful request

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiBadge, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { euiLightVars } from '@kbn/ui-theme';
import React from 'react';
export interface Props {
slo: SLOWithSummaryResponse;
}
export function SloGroupByBadge({ slo }: Props) {
if (!slo.groupBy || slo.groupBy === ALL_VALUE) {
return null;
}
return (
<EuiFlexItem grow={false}>
<EuiBadge color={euiLightVars.euiColorDisabled}>
<EuiToolTip
position="top"
content={i18n.translate('xpack.observability.slo.groupByBadge', {
defaultMessage: 'Group by {groupBy}',
values: {
groupBy: slo.groupBy,
},
})}
display="block"
>
<span>
{slo.groupBy}: {slo.instanceId}
</span>
</EuiToolTip>
</EuiBadge>
</EuiFlexItem>
);
}

View file

@ -7,7 +7,7 @@
import { cloneDeep } from 'lodash';
import { v1 as uuidv1 } from 'uuid';
import { FindSLOResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { ALL_VALUE, FindSLOResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import {
buildDegradingSummary,
buildHealthySummary,
@ -62,6 +62,8 @@ const baseSlo: Omit<SLOWithSummaryResponse, 'id'> = {
isEstimated: false,
},
},
groupBy: ALL_VALUE,
instanceId: ALL_VALUE,
tags: ['k8s', 'production', 'critical'],
enabled: true,
createdAt: now,

View file

@ -11,7 +11,7 @@ import {
RefetchQueryFilters,
useQuery,
} from '@tanstack/react-query';
import { GetSLOResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { ALL_VALUE, GetSLOResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useKibana } from '../../utils/kibana_react';
import { sloKeys } from './query_key_factory';
@ -31,9 +31,11 @@ const LONG_REFETCH_INTERVAL = 1000 * 60; // 1 minute
export function useFetchSloDetails({
sloId,
instanceId,
shouldRefetch,
}: {
sloId?: string;
instanceId?: string;
shouldRefetch?: boolean;
}): UseFetchSloDetailsResponse {
const { http } = useKibana().services;
@ -44,7 +46,9 @@ export function useFetchSloDetails({
queryFn: async ({ signal }) => {
try {
const response = await http.get<GetSLOResponse>(`/api/observability/slos/${sloId}`, {
query: {},
query: {
...(!!instanceId && instanceId !== ALL_VALUE && { instanceId }),
},
signal,
});

View file

@ -0,0 +1,56 @@
/*
* 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 { GetSLOInstancesResponse } from '@kbn/slo-schema';
import { useQuery } from '@tanstack/react-query';
import { useKibana } from '../../utils/kibana_react';
import { sloKeys } from './query_key_factory';
export interface UseFetchSloInstancesResponse {
isInitialLoading: boolean;
isLoading: boolean;
isRefetching: boolean;
isSuccess: boolean;
isError: boolean;
data: GetSLOInstancesResponse | undefined;
}
export function useFetchSloInstances({ sloId }: { sloId?: string }): UseFetchSloInstancesResponse {
const { http } = useKibana().services;
const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({
queryKey: sloKeys.detail(sloId),
queryFn: async ({ signal }) => {
try {
const response = await http.get<GetSLOInstancesResponse>(
`/internal/observability/slos/${sloId}/_instances`,
{
query: {},
signal,
}
);
return response;
} catch (error) {
// ignore error for retrieving slos
}
},
keepPreviousData: true,
enabled: Boolean(sloId),
refetchOnWindowFocus: false,
});
return {
data,
isLoading,
isInitialLoading,
isRefetching,
isSuccess,
isError,
};
}

View file

@ -20,7 +20,7 @@ describe('SloEditLocator', () => {
it('should return correct url when slo is provided', async () => {
const location = await locator.getLocation(buildSlo({ id: 'foo' }));
expect(location.path).toEqual(
"/slos/edit/foo?_a=(budgetingMethod:occurrences,createdAt:'2022-12-29T10:11:12.000Z',description:'some%20description%20useful',enabled:!t,id:foo,indicator:(params:(filter:'baz:%20foo%20and%20bar%20%3E%202',good:'http_status:%202xx',index:some-index,timestampField:custom_timestamp,total:'a%20query'),type:sli.kql.custom),name:'super%20important%20level%20service',objective:(target:0.98),revision:1,settings:(frequency:'1m',syncDelay:'1m'),summary:(errorBudget:(consumed:0.064,initial:0.02,isEstimated:!f,remaining:0.936),sliValue:0.99872,status:HEALTHY),tags:!(k8s,production,critical),timeWindow:(duration:'30d',type:rolling),updatedAt:'2022-12-29T10:11:12.000Z')"
"/slos/edit/foo?_a=(budgetingMethod:occurrences,createdAt:'2022-12-29T10:11:12.000Z',description:'some%20description%20useful',enabled:!t,groupBy:'*',id:foo,indicator:(params:(filter:'baz:%20foo%20and%20bar%20%3E%202',good:'http_status:%202xx',index:some-index,timestampField:custom_timestamp,total:'a%20query'),type:sli.kql.custom),instanceId:'*',name:'super%20important%20level%20service',objective:(target:0.98),revision:1,settings:(frequency:'1m',syncDelay:'1m'),summary:(errorBudget:(consumed:0.064,initial:0.02,isEstimated:!f,remaining:0.936),sliValue:0.99872,status:HEALTHY),tags:!(k8s,production,critical),timeWindow:(duration:'30d',type:rolling),updatedAt:'2022-12-29T10:11:12.000Z')"
);
});
});

View file

@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import moment from 'moment';
import React from 'react';
import { SloGroupByBadge } from '../../../components/slo/slo_status_badge/slo_group_by_badge';
import { SloStatusBadge } from '../../../components/slo/slo_status_badge';
export interface Props {
@ -33,8 +34,15 @@ export function HeaderTitle(props: Props) {
<EuiFlexGroup gutterSize="s">
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={false}>{slo.name}</EuiFlexItem>
<EuiFlexGroup direction="row" gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexGroup
direction="row"
gutterSize="s"
alignItems="center"
justifyContent="flexStart"
responsive={false}
>
<SloStatusBadge slo={slo} />
<SloGroupByBadge slo={slo} />
</EuiFlexGroup>
</EuiFlexGroup>
</EuiFlexGroup>

View file

@ -7,7 +7,11 @@
import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { APMTransactionDurationIndicator, APMTransactionErrorRateIndicator } from '@kbn/slo-schema';
import {
ALL_VALUE,
APMTransactionDurationIndicator,
APMTransactionErrorRateIndicator,
} from '@kbn/slo-schema';
import React from 'react';
import { useKibana } from '../../../../utils/kibana_react';
import { convertSliApmParamsToApmAppDeeplinkUrl } from '../../../../utils/slo/convert_sli_apm_params_to_apm_app_deeplink_url';
@ -48,7 +52,7 @@ export function ApmIndicatorOverview({ indicator }: Props) {
)}
</EuiBadge>
</EuiFlexItem>
{environment !== '*' && (
{environment !== ALL_VALUE && (
<EuiFlexItem grow={false}>
<EuiBadge color="hollow" href={link}>
{i18n.translate(
@ -58,7 +62,7 @@ export function ApmIndicatorOverview({ indicator }: Props) {
</EuiBadge>
</EuiFlexItem>
)}
{transactionType !== '*' && (
{transactionType !== ALL_VALUE && (
<EuiFlexItem grow={false}>
<EuiBadge color="hollow" href={link}>
{i18n.translate(
@ -68,7 +72,7 @@ export function ApmIndicatorOverview({ indicator }: Props) {
</EuiBadge>
</EuiFlexItem>
)}
{transactionName !== '*' && (
{transactionName !== ALL_VALUE && (
<EuiFlexItem grow={false}>
<EuiBadge color="hollow" href={link}>
{i18n.translate(

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ALL_VALUE } from '@kbn/slo-schema';
import { useLocation } from 'react-router-dom';
export const INSTANCE_SEARCH_PARAM = 'instanceId';
export function useGetInstanceIdQueryParam(): string | undefined {
const { search } = useLocation();
const searchParams = new URLSearchParams(search);
const instanceId = searchParams.get(INSTANCE_SEARCH_PARAM);
return !!instanceId && instanceId !== ALL_VALUE ? instanceId : undefined;
}

View file

@ -237,7 +237,17 @@ describe('SLO Details Page', () => {
fireEvent.click(button!);
const { id, createdAt, enabled, revision, summary, settings, updatedAt, ...newSlo } = slo;
const {
id,
createdAt,
enabled,
revision,
summary,
settings,
updatedAt,
instanceId,
...newSlo
} = slo;
expect(mockClone).toBeCalledWith({
originalSloId: slo.id,

View file

@ -27,6 +27,7 @@ import { paths } from '../../routes/paths';
import type { SloDetailsPathParams } from './types';
import { AutoRefreshButton } from '../slos/components/auto_refresh_button';
import { FeedbackButton } from '../../components/slo/feedback_button/feedback_button';
import { useGetInstanceIdQueryParam } from './hooks/use_get_instance_id_query_param';
export function SloDetailsPage() {
const {
@ -39,8 +40,13 @@ export function SloDetailsPage() {
const hasRightLicense = hasAtLeast('platinum');
const { sloId } = useParams<SloDetailsPathParams>();
const sloInstanceId = useGetInstanceIdQueryParam();
const [isAutoRefreshing, setIsAutoRefreshing] = useState(true);
const { isLoading, slo } = useFetchSloDetails({ sloId, shouldRefetch: isAutoRefreshing });
const { isLoading, slo } = useFetchSloDetails({
sloId,
instanceId: sloInstanceId,
shouldRefetch: isAutoRefreshing,
});
const isCloningOrDeleting = Boolean(useIsMutating());
useBreadcrumbs(getBreadcrumbs(basePath, slo));

View file

@ -7,6 +7,7 @@
import { EuiComboBox, EuiComboBoxOptionOption, EuiFlexItem, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ALL_VALUE } from '@kbn/slo-schema';
import { debounce } from 'lodash';
import React, { ReactNode, useState } from 'react';
import { Controller, FieldPath, useFormContext } from 'react-hook-form';
@ -55,7 +56,7 @@ export function FieldSelector({
allowAllOption
? [
{
value: '*',
value: ALL_VALUE,
label: i18n.translate('xpack.observability.slo.sloEdit.fieldSelector.all', {
defaultMessage: 'All',
}),

View file

@ -7,6 +7,7 @@
import { i18n } from '@kbn/i18n';
import {
ALL_VALUE,
APMTransactionDurationIndicator,
APMTransactionErrorRateIndicator,
BudgetingMethod,
@ -179,6 +180,7 @@ export const SLO_EDIT_FORM_DEFAULT_VALUES: CreateSLOForm = {
objective: {
target: 99,
},
groupBy: ALL_VALUE,
};
export const SLO_EDIT_FORM_DEFAULT_VALUES_CUSTOM_METRIC: CreateSLOForm = {
@ -194,4 +196,5 @@ export const SLO_EDIT_FORM_DEFAULT_VALUES_CUSTOM_METRIC: CreateSLOForm = {
objective: {
target: 99,
},
groupBy: ALL_VALUE,
};

View file

@ -34,6 +34,7 @@ export function transformSloResponseToCreateSloForm(
timesliceWindow: String(toDuration(values.objective.timesliceWindow).value),
}),
},
groupBy: values.groupBy,
tags: values.tags,
};
}
@ -60,6 +61,7 @@ export function transformCreateSLOFormToCreateSLOInput(values: CreateSLOForm): C
}),
},
tags: values.tags,
groupBy: values.groupBy,
};
}
@ -85,6 +87,7 @@ export function transformValuesToUpdateSLOInput(values: CreateSLOForm): UpdateSL
}),
},
tags: values.tags,
groupBy: values.groupBy,
};
}

View file

@ -22,4 +22,5 @@ export interface CreateSLOForm {
timesliceTarget?: number;
timesliceWindow?: string;
};
groupBy: string;
}

View file

@ -17,6 +17,7 @@ import { SloTimeWindowBadge } from './slo_time_window_badge';
import { SloRulesBadge } from './slo_rules_badge';
import type { ActiveAlerts } from '../../../../hooks/slo/use_fetch_active_alerts';
import type { SloRule } from '../../../../hooks/slo/use_fetch_rules_for_slo';
import { SloGroupByBadge } from '../../../../components/slo/slo_status_badge/slo_group_by_badge';
export interface Props {
activeAlerts?: ActiveAlerts;
@ -56,6 +57,7 @@ export function SloBadges({ activeAlerts, isLoading, rules, slo, onClickRuleBadg
) : (
<>
<SloStatusBadge slo={slo} />
<SloGroupByBadge slo={slo} />
<SloIndicatorTypeBadge slo={slo} />
<SloTimeWindowBadge slo={slo} />
<SloActiveAlertsBadge slo={slo} activeAlerts={activeAlerts} />

View file

@ -17,7 +17,7 @@ import {
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { HistoricalSummaryResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { ALL_VALUE, HistoricalSummaryResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import type { Rule } from '@kbn/triggers-actions-ui-plugin/public';
import { useQueryClient } from '@tanstack/react-query';
import React, { useState } from 'react';
@ -81,7 +81,14 @@ export function SloListItem({
};
const handleViewDetails = () => {
navigateToUrl(basePath.prepend(paths.observability.sloDetails(slo.id)));
navigateToUrl(
basePath.prepend(
paths.observability.sloDetails(
slo.id,
slo.groupBy !== ALL_VALUE && slo.instanceId ? slo.instanceId : undefined
)
)
);
};
const handleEdit = () => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema';
import React from 'react';
import { useFetchActiveAlerts } from '../../../hooks/slo/use_fetch_active_alerts';
import { useFetchHistoricalSummary } from '../../../hooks/slo/use_fetch_historical_summary';
@ -38,7 +38,7 @@ export function SloListItems({ sloList, loading, error }: Props) {
return (
<EuiFlexGroup direction="column" gutterSize="s">
{sloList.map((slo) => (
<EuiFlexItem key={slo.id}>
<EuiFlexItem key={`${slo.id}-${slo.instanceId ?? ALL_VALUE}`}>
<SloListItem
activeAlerts={activeAlertsBySlo[slo.id]}
rules={rulesBySlo?.[slo.id]}

View file

@ -34,6 +34,11 @@ export const paths = {
sloCreateWithEncodedForm: (encodedParams: string) =>
`${OBSERVABILITY_BASE_PATH}${SLO_CREATE_PATH}?_a=${encodedParams}`,
sloEdit: (sloId: string) => `${OBSERVABILITY_BASE_PATH}${SLOS_PATH}/edit/${encodeURI(sloId)}`,
sloDetails: (sloId: string) => `${OBSERVABILITY_BASE_PATH}${SLOS_PATH}/${encodeURI(sloId)}`,
sloDetails: (sloId: string, instanceId?: string) =>
!!instanceId
? `${OBSERVABILITY_BASE_PATH}${SLOS_PATH}/${encodeURI(sloId)}?instanceId=${encodeURI(
instanceId
)}`
: `${OBSERVABILITY_BASE_PATH}${SLOS_PATH}/${encodeURI(sloId)}`,
},
};

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { ALL_VALUE } from '@kbn/slo-schema';
interface Props {
duration?: string;
environment: string;
@ -29,11 +31,11 @@ export function convertSliApmParamsToApmAppDeeplinkUrl({
const qs = new URLSearchParams('comparisonEnabled=true');
if (environment) {
qs.append('environment', environment === '*' ? 'ENVIRONMENT_ALL' : environment);
qs.append('environment', environment === ALL_VALUE ? 'ENVIRONMENT_ALL' : environment);
}
if (transactionType) {
qs.append('transactionType', transactionType === '*' ? '' : transactionType);
qs.append('transactionType', transactionType === ALL_VALUE ? '' : transactionType);
}
if (duration) {
@ -42,7 +44,7 @@ export function convertSliApmParamsToApmAppDeeplinkUrl({
}
const kueryParams = [];
if (transactionName && transactionName !== '*') {
if (transactionName && transactionName !== ALL_VALUE) {
kueryParams.push(`transaction.name : "${transactionName}"`);
}
if (filter && filter.length > 0) {

View file

@ -50,6 +50,10 @@ export const getSLOMappingsTemplate = (name: string) => ({
revision: {
type: 'long',
},
groupBy: {
type: 'keyword',
ignore_above: 256,
},
instanceId: {
type: 'keyword',
ignore_above: 256,

View file

@ -46,6 +46,10 @@ export const getSLOSummaryMappingsTemplate = (name: string) => ({
revision: {
type: 'long',
},
groupBy: {
type: 'keyword',
ignore_above: 256,
},
instanceId: {
type: 'keyword',
ignore_above: 256,

View file

@ -14,6 +14,7 @@ import {
getPreviewDataParamsSchema,
getSLOBurnRatesParamsSchema,
getSLODiagnosisParamsSchema,
getSLOInstancesParamsSchema,
getSLOParamsSchema,
manageSLOParamsSchema,
updateSLOParamsSchema,
@ -33,6 +34,7 @@ import { FetchHistoricalSummary } from '../../services/slo/fetch_historical_summ
import { getBurnRates } from '../../services/slo/get_burn_rates';
import { getGlobalDiagnosis, getSloDiagnosis } from '../../services/slo/get_diagnosis';
import { GetPreviewData } from '../../services/slo/get_preview_data';
import { GetSLOInstances } from '../../services/slo/get_slo_instances';
import { DefaultHistoricalSummaryClient } from '../../services/slo/historical_summary_client';
import { ManageSLO } from '../../services/slo/manage_slo';
import { DefaultSummarySearchClient } from '../../services/slo/summary_search_client';
@ -162,7 +164,7 @@ const getSLORoute = createObservabilityServerRoute({
const summaryClient = new DefaultSummaryClient(esClient);
const getSLO = new GetSLO(repository, summaryClient);
const response = await getSLO.execute(params.path.id);
const response = await getSLO.execute(params.path.id, params.query);
return response;
},
@ -271,6 +273,31 @@ const fetchHistoricalSummary = createObservabilityServerRoute({
},
});
const getSLOInstancesRoute = createObservabilityServerRoute({
endpoint: 'GET /internal/observability/slos/{id}/_instances',
options: {
tags: ['access:slo_read'],
},
params: getSLOInstancesParamsSchema,
handler: async ({ context, params }) => {
const hasCorrectLicense = await isLicenseAtLeastPlatinum(context);
if (!hasCorrectLicense) {
throw forbidden('Platinum license or higher is needed to make use of this feature.');
}
const soClient = (await context.core).savedObjects.client;
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const repository = new KibanaSavedObjectsSLORepository(soClient);
const getSLOInstances = new GetSLOInstances(repository, esClient);
const response = await getSLOInstances.execute(params.path.id);
return response;
},
});
const getDiagnosisRoute = createObservabilityServerRoute({
endpoint: 'GET /internal/observability/slos/_diagnosis',
options: {
@ -363,4 +390,5 @@ export const sloRouteRepository = {
...getSloDiagnosisRoute,
...getSloBurnRates,
...getPreviewData,
...getSLOInstancesRoute,
};

View file

@ -18,6 +18,7 @@ Array [
"slo": Object {
"budgetingMethod": "occurrences",
"description": "irrelevant",
"groupBy": "*",
"id": "unique-id",
"indicator": Object {
"type": "sli.apm.transactionErrorRate",

View file

@ -0,0 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Get SLO Instances returns all instances of a SLO defined with a 'groupBy' 1`] = `
Array [
Object {
"aggs": Object {
"instances": Object {
"terms": Object {
"field": "slo.instanceId",
"size": 1000,
},
},
},
"index": ".slo-observability.sli-v2*",
"query": Object {
"bool": Object {
"filter": Array [
Object {
"range": Object {
"@timestamp": Object {
"gte": "now-7d",
},
},
},
Object {
"term": Object {
"slo.id": "slo-id",
},
},
Object {
"term": Object {
"slo.revision": 2,
},
},
],
},
},
"size": 0,
},
]
`;

View file

@ -3,38 +3,38 @@
exports[`SummaryClient fetchSummary with calendar aligned and timeslices SLO returns the summary 1`] = `
Object {
"errorBudget": Object {
"consumed": 0.198413,
"consumed": 0,
"initial": 0.05,
"isEstimated": false,
"remaining": 0.801587,
"remaining": 1,
},
"sliValue": 0.9,
"status": "DEGRADING",
"sliValue": -1,
"status": "NO_DATA",
}
`;
exports[`SummaryClient fetchSummary with rolling and occurrences SLO returns the summary 1`] = `
Object {
"errorBudget": Object {
"consumed": 100,
"consumed": 0,
"initial": 0.001,
"isEstimated": false,
"remaining": -99,
"remaining": 1,
},
"sliValue": 0.9,
"status": "VIOLATED",
"sliValue": -1,
"status": "NO_DATA",
}
`;
exports[`SummaryClient fetchSummary with rolling and timeslices SLO returns the summary 1`] = `
Object {
"errorBudget": Object {
"consumed": 2,
"consumed": 0,
"initial": 0.05,
"isEstimated": false,
"remaining": -1,
"remaining": 1,
},
"sliValue": 0.9,
"status": "VIOLATED",
"sliValue": -1,
"status": "NO_DATA",
}
`;

View file

@ -37,6 +37,7 @@ Object {
"results": Array [
Object {
"id": "slo-one",
"instanceId": "*",
"summary": Object {
"errorBudget": Object {
"consumed": 0.4,
@ -50,6 +51,7 @@ Object {
},
Object {
"id": "slo_two",
"instanceId": "*",
"summary": Object {
"errorBudget": Object {
"consumed": 0.4,
@ -63,6 +65,7 @@ Object {
},
Object {
"id": "slo-three",
"instanceId": "*",
"summary": Object {
"errorBudget": Object {
"consumed": 0.4,
@ -76,6 +79,7 @@ Object {
},
Object {
"id": "slo-five",
"instanceId": "*",
"summary": Object {
"errorBudget": Object {
"consumed": 0.4,
@ -89,6 +93,7 @@ Object {
},
Object {
"id": "slo-four",
"instanceId": "*",
"summary": Object {
"errorBudget": Object {
"consumed": 0.4,

View file

@ -18,6 +18,7 @@ Array [
"slo": Object {
"budgetingMethod": "occurrences",
"description": "irrelevant",
"groupBy": "*",
"id": "unique-id",
"indicator": Object {
"type": "sli.apm.transactionErrorRate",

View file

@ -6,7 +6,7 @@
*/
import { ElasticsearchClient } from '@kbn/core/server';
import { CreateSLOParams, CreateSLOResponse } from '@kbn/slo-schema';
import { ALL_VALUE, CreateSLOParams, CreateSLOResponse } from '@kbn/slo-schema';
import { v1 as uuidv1 } from 'uuid';
import { SLO_SUMMARY_TEMP_INDEX_NAME } from '../../assets/constants';
import { Duration, DurationUnit, SLO } from '../../domain/models';
@ -69,6 +69,7 @@ export class CreateSLO {
tags: params.tags ?? [],
createdAt: now,
updatedAt: now,
groupBy: !!params.groupBy ? params.groupBy : ALL_VALUE,
};
}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { ALL_VALUE } from '@kbn/slo-schema';
import { SLO } from '../../domain/models';
import { FindSLO } from './find_slo';
import { createSLO } from './fixtures/slo';
@ -92,6 +93,8 @@ describe('FindSLO', () => {
updatedAt: slo.updatedAt.toISOString(),
enabled: slo.enabled,
revision: slo.revision,
groupBy: slo.groupBy,
instanceId: ALL_VALUE,
},
],
});
@ -145,6 +148,7 @@ function summarySearchResult(slo: SLO): Paginated<SLOSummary> {
results: [
{
id: slo.id,
instanceId: slo.groupBy === ALL_VALUE ? ALL_VALUE : 'host-abcde',
summary: {
status: 'HEALTHY',
sliValue: 0.9999,

View file

@ -43,6 +43,7 @@ function mergeSloWithSummary(sloList: SLO[], sloSummaryList: SLOSummary[]): SLOW
.filter((sloSummary) => sloList.some((s) => s.id === sloSummary.id))
.map((sloSummary) => ({
...sloList.find((s) => s.id === sloSummary.id)!,
instanceId: sloSummary.instanceId,
summary: sloSummary.summary,
}));
}

View file

@ -6,7 +6,7 @@
*/
import { SavedObject } from '@kbn/core-saved-objects-server';
import { CreateSLOParams, HistogramIndicator, sloSchema } from '@kbn/slo-schema';
import { ALL_VALUE, CreateSLOParams, HistogramIndicator, sloSchema } from '@kbn/slo-schema';
import { cloneDeep } from 'lodash';
import { v1 as uuidv1 } from 'uuid';
import {
@ -129,6 +129,7 @@ const defaultSLO: Omit<SLO, 'id' | 'revision' | 'createdAt' | 'updatedAt'> = {
},
tags: ['critical', 'k8s'],
enabled: true,
groupBy: ALL_VALUE,
};
const defaultCreateSloParams: CreateSLOParams = {

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { ALL_VALUE } from '@kbn/slo-schema';
import { v1 as uuidv1 } from 'uuid';
export const aSummaryDocument = ({
@ -33,7 +34,7 @@ export const aSummaryDocument = ({
duration: '30d',
type: 'rolling',
},
instanceId: '*',
instanceId: ALL_VALUE,
name: 'irrelevant',
description: '',
id,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { ALL_VALUE } from '@kbn/slo-schema';
import { createAPMTransactionErrorRateIndicator, createSLO } from './fixtures/slo';
import { GetSLO } from './get_slo';
import { createSummaryClientMock, createSLORepositoryMock } from './mocks';
@ -26,16 +27,14 @@ describe('GetSLO', () => {
it('retrieves the SLO from the repository', async () => {
const slo = createSLO({ indicator: createAPMTransactionErrorRateIndicator() });
mockRepository.findById.mockResolvedValueOnce(slo);
mockSummaryClient.fetchSummary.mockResolvedValueOnce({
[slo.id]: {
status: 'HEALTHY',
sliValue: 0.9999,
errorBudget: {
initial: 0.001,
consumed: 0.1,
remaining: 0.9,
isEstimated: false,
},
mockSummaryClient.computeSummary.mockResolvedValueOnce({
status: 'HEALTHY',
sliValue: 0.9999,
errorBudget: {
initial: 0.001,
consumed: 0.1,
remaining: 0.9,
isEstimated: false,
},
});
@ -83,6 +82,8 @@ describe('GetSLO', () => {
updatedAt: slo.updatedAt.toISOString(),
enabled: slo.enabled,
revision: slo.revision,
groupBy: slo.groupBy,
instanceId: ALL_VALUE,
});
});
});

View file

@ -5,24 +5,23 @@
* 2.0.
*/
import { GetSLOResponse, getSLOResponseSchema } from '@kbn/slo-schema';
import { SLO, SLOId, SLOWithSummary, Summary } from '../../domain/models';
import { ALL_VALUE, GetSLOParams, GetSLOResponse, getSLOResponseSchema } from '@kbn/slo-schema';
import { SLO, Summary } from '../../domain/models';
import { SLORepository } from './slo_repository';
import { SummaryClient } from './summary_client';
export class GetSLO {
constructor(private repository: SLORepository, private summaryClient: SummaryClient) {}
public async execute(sloId: string): Promise<GetSLOResponse> {
public async execute(sloId: string, params: GetSLOParams = {}): Promise<GetSLOResponse> {
const slo = await this.repository.findById(sloId);
const summaryBySlo = await this.summaryClient.fetchSummary([slo]);
const instanceId = params.instanceId ?? ALL_VALUE;
const summary = await this.summaryClient.computeSummary(slo, instanceId);
const sloWithSummary = mergeSloWithSummary(slo, summaryBySlo);
return getSLOResponseSchema.encode(sloWithSummary);
return getSLOResponseSchema.encode(mergeSloWithSummary(slo, summary, instanceId));
}
}
function mergeSloWithSummary(slo: SLO, summaryBySlo: Record<SLOId, Summary>): SLOWithSummary {
return { ...slo, summary: summaryBySlo[slo.id] };
function mergeSloWithSummary(slo: SLO, summary: Summary, instanceId: string) {
return { ...slo, instanceId, summary };
}

View file

@ -0,0 +1,70 @@
/*
* 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 { ElasticsearchClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { createSLO } from './fixtures/slo';
import { GetSLOInstances, SLORepository } from '.';
import { createSLORepositoryMock } from './mocks';
import { ALL_VALUE } from '@kbn/slo-schema';
describe('Get SLO Instances', () => {
let repositoryMock: jest.Mocked<SLORepository>;
let esClientMock: ElasticsearchClientMock;
beforeEach(() => {
repositoryMock = createSLORepositoryMock();
esClientMock = elasticsearchServiceMock.createElasticsearchClient();
});
it("returns an empty response when the SLO has no 'groupBy' defined", async () => {
const slo = createSLO({ groupBy: ALL_VALUE });
repositoryMock.findById.mockResolvedValue(slo);
const service = new GetSLOInstances(repositoryMock, esClientMock);
const result = await service.execute(slo.id);
expect(result).toEqual({ groupBy: ALL_VALUE, instances: [] });
});
it("returns all instances of a SLO defined with a 'groupBy'", async () => {
const slo = createSLO({ id: 'slo-id', revision: 2, groupBy: 'field.to.host' });
repositoryMock.findById.mockResolvedValue(slo);
esClientMock.search.mockResolvedValue({
took: 100,
timed_out: false,
_shards: {
total: 0,
successful: 0,
skipped: 0,
failed: 0,
},
hits: {
hits: [],
},
aggregations: {
instances: {
buckets: [
{ key: 'host-aaa', doc_value: 100 },
{ key: 'host-bbb', doc_value: 200 },
{ key: 'host-ccc', doc_value: 500 },
],
},
},
});
const service = new GetSLOInstances(repositoryMock, esClientMock);
const result = await service.execute(slo.id);
expect(result).toEqual({
groupBy: 'field.to.host',
instances: ['host-aaa', 'host-bbb', 'host-ccc'],
});
expect(esClientMock.search.mock.calls[0]).toMatchSnapshot();
});
});

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { ALL_VALUE, GetSLOInstancesResponse } from '@kbn/slo-schema';
import { SLO_DESTINATION_INDEX_PATTERN } from '../../assets/constants';
import { SLORepository } from './slo_repository';
export class GetSLOInstances {
constructor(private repository: SLORepository, private esClient: ElasticsearchClient) {}
public async execute(sloId: string): Promise<GetSLOInstancesResponse> {
const slo = await this.repository.findById(sloId);
if (slo.groupBy === ALL_VALUE) {
return { groupBy: ALL_VALUE, instances: [] };
}
const result = await this.esClient.search({
index: SLO_DESTINATION_INDEX_PATTERN,
size: 0,
query: {
bool: {
filter: [
{ range: { '@timestamp': { gte: 'now-7d' } } },
{ term: { 'slo.id': slo.id } },
{ term: { 'slo.revision': slo.revision } },
],
},
},
aggs: {
instances: {
terms: {
size: 1000,
field: 'slo.instanceId',
},
},
},
});
// @ts-ignore
const buckets = result?.aggregations?.instances.buckets ?? [];
const instances = buckets.map((bucket: { key: string }) => bucket.key);
return { groupBy: slo.groupBy, instances };
}
}

View file

@ -19,3 +19,4 @@ export * from './slo_repository';
export * from './transform_manager';
export * from './update_slo';
export * from './summary_client';
export * from './get_slo_instances';

View file

@ -45,7 +45,7 @@ const createSLORepositoryMock = (): jest.Mocked<SLORepository> => {
const createSummaryClientMock = (): jest.Mocked<SummaryClient> => {
return {
fetchSummary: jest.fn(),
computeSummary: jest.fn(),
};
};

View file

@ -54,33 +54,30 @@ describe('SummaryClient', () => {
esClientMock.msearch.mockResolvedValueOnce(createEsResponse());
const summaryClient = new DefaultSummaryClient(esClientMock);
const result = await summaryClient.fetchSummary([slo]);
const result = await summaryClient.computeSummary(slo);
expect(result[slo.id]).toMatchSnapshot();
// @ts-ignore
expect(esClientMock.msearch.mock.calls[0][0].searches).toEqual([
{ index: SLO_DESTINATION_INDEX_PATTERN },
{
size: 0,
query: {
bool: {
filter: [
{ term: { 'slo.id': slo.id } },
{ term: { 'slo.revision': slo.revision } },
{
range: {
'@timestamp': { gte: expect.anything(), lt: expect.anything() },
},
expect(result).toMatchSnapshot();
expect(esClientMock.search.mock.calls[0][0]).toEqual({
index: SLO_DESTINATION_INDEX_PATTERN,
size: 0,
query: {
bool: {
filter: [
{ term: { 'slo.id': slo.id } },
{ term: { 'slo.revision': slo.revision } },
{
range: {
'@timestamp': { gte: expect.anything(), lt: expect.anything() },
},
],
},
},
aggs: {
good: { sum: { field: 'slo.numerator' } },
total: { sum: { field: 'slo.denominator' } },
},
],
},
},
]);
aggs: {
good: { sum: { field: 'slo.numerator' } },
total: { sum: { field: 'slo.denominator' } },
},
});
});
});
@ -92,35 +89,32 @@ describe('SummaryClient', () => {
esClientMock.msearch.mockResolvedValueOnce(createEsResponse());
const summaryClient = new DefaultSummaryClient(esClientMock);
await summaryClient.fetchSummary([slo]);
await summaryClient.computeSummary(slo);
// @ts-ignore
expect(esClientMock.msearch.mock.calls[0][0].searches).toEqual([
{ index: SLO_DESTINATION_INDEX_PATTERN },
{
size: 0,
query: {
bool: {
filter: [
{ term: { 'slo.id': slo.id } },
{ term: { 'slo.revision': slo.revision } },
{
range: {
'@timestamp': {
gte: expect.anything(),
lt: expect.anything(),
},
expect(esClientMock.search.mock.calls[0][0]).toEqual({
index: SLO_DESTINATION_INDEX_PATTERN,
size: 0,
query: {
bool: {
filter: [
{ term: { 'slo.id': slo.id } },
{ term: { 'slo.revision': slo.revision } },
{
range: {
'@timestamp': {
gte: expect.anything(),
lt: expect.anything(),
},
},
],
},
},
aggs: {
good: { sum: { field: 'slo.numerator' } },
total: { sum: { field: 'slo.denominator' } },
},
],
},
},
]);
aggs: {
good: { sum: { field: 'slo.numerator' } },
total: { sum: { field: 'slo.denominator' } },
},
});
});
});
@ -138,41 +132,38 @@ describe('SummaryClient', () => {
esClientMock.msearch.mockResolvedValueOnce(createEsResponse());
const summaryClient = new DefaultSummaryClient(esClientMock);
const result = await summaryClient.fetchSummary([slo]);
const result = await summaryClient.computeSummary(slo);
expect(result[slo.id]).toMatchSnapshot();
// @ts-ignore searches not typed properly
expect(esClientMock.msearch.mock.calls[0][0].searches).toEqual([
{ index: SLO_DESTINATION_INDEX_PATTERN },
{
size: 0,
query: {
bool: {
filter: [
{ term: { 'slo.id': slo.id } },
{ term: { 'slo.revision': slo.revision } },
{
range: {
'@timestamp': { gte: expect.anything(), lt: expect.anything() },
},
expect(result).toMatchSnapshot();
expect(esClientMock.search.mock.calls[0][0]).toEqual({
index: SLO_DESTINATION_INDEX_PATTERN,
size: 0,
query: {
bool: {
filter: [
{ term: { 'slo.id': slo.id } },
{ term: { 'slo.revision': slo.revision } },
{
range: {
'@timestamp': { gte: expect.anything(), lt: expect.anything() },
},
],
},
],
},
},
aggs: {
good: {
sum: {
field: 'slo.isGoodSlice',
},
},
aggs: {
good: {
sum: {
field: 'slo.isGoodSlice',
},
},
total: {
value_count: {
field: 'slo.isGoodSlice',
},
total: {
value_count: {
field: 'slo.isGoodSlice',
},
},
},
]);
});
});
});
@ -190,44 +181,42 @@ describe('SummaryClient', () => {
esClientMock.msearch.mockResolvedValueOnce(createEsResponse());
const summaryClient = new DefaultSummaryClient(esClientMock);
const result = await summaryClient.fetchSummary([slo]);
const result = await summaryClient.computeSummary(slo);
expect(result[slo.id]).toMatchSnapshot();
// @ts-ignore searches not typed properly
expect(esClientMock.msearch.mock.calls[0][0].searches).toEqual([
{ index: SLO_DESTINATION_INDEX_PATTERN },
{
size: 0,
query: {
bool: {
filter: [
{ term: { 'slo.id': slo.id } },
{ term: { 'slo.revision': slo.revision } },
{
range: {
'@timestamp': {
gte: expect.anything(),
lt: expect.anything(),
},
expect(result).toMatchSnapshot();
expect(esClientMock.search.mock.calls[0][0]).toEqual({
index: SLO_DESTINATION_INDEX_PATTERN,
size: 0,
query: {
bool: {
filter: [
{ term: { 'slo.id': slo.id } },
{ term: { 'slo.revision': slo.revision } },
{
range: {
'@timestamp': {
gte: expect.anything(),
lt: expect.anything(),
},
},
],
},
],
},
},
aggs: {
good: {
sum: {
field: 'slo.isGoodSlice',
},
},
aggs: {
good: {
sum: {
field: 'slo.isGoodSlice',
},
},
total: {
value_count: {
field: 'slo.isGoodSlice',
},
total: {
value_count: {
field: 'slo.isGoodSlice',
},
},
},
]);
});
});
});
});

View file

@ -5,9 +5,9 @@
* 2.0.
*/
import { MsearchMultisearchBody } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { ElasticsearchClient } from '@kbn/core/server';
import {
ALL_VALUE,
calendarAlignedTimeWindowSchema,
Duration,
occurrencesBudgetingMethodSchema,
@ -16,75 +16,96 @@ import {
} from '@kbn/slo-schema';
import moment from 'moment';
import { SLO_DESTINATION_INDEX_PATTERN } from '../../assets/constants';
import { DateRange, SLO, SLOId, Summary } from '../../domain/models';
import { DateRange, SLO, Summary } from '../../domain/models';
import { computeSLI, computeSummaryStatus, toErrorBudget } from '../../domain/services';
import { toDateRange } from '../../domain/services/date_range';
// TODO: Change name of this service...
// It does compute a summary but from the rollup data.
export interface SummaryClient {
fetchSummary(sloList: SLO[]): Promise<Record<SLOId, Summary>>;
computeSummary(slo: SLO, instanceId?: string): Promise<Summary>;
}
export class DefaultSummaryClient implements SummaryClient {
constructor(private esClient: ElasticsearchClient) {}
async fetchSummary(sloList: SLO[]): Promise<Record<SLOId, Summary>> {
const dateRangeBySlo = sloList.reduce<Record<SLOId, DateRange>>((acc, slo) => {
acc[slo.id] = toDateRange(slo.timeWindow);
return acc;
}, {});
const searches = sloList.flatMap((slo) => [
{ index: SLO_DESTINATION_INDEX_PATTERN },
generateSearchQuery(slo, dateRangeBySlo[slo.id]),
]);
async computeSummary(slo: SLO, instanceId: string = ALL_VALUE): Promise<Summary> {
const dateRange = toDateRange(slo.timeWindow);
const isDefinedWithGroupBy = slo.groupBy !== ALL_VALUE;
const hasInstanceId = instanceId !== ALL_VALUE;
const extraInstanceIdFilter =
isDefinedWithGroupBy && hasInstanceId ? [{ term: { 'slo.instanceId': instanceId } }] : [];
const summaryBySlo: Record<SLOId, Summary> = {};
if (searches.length === 0) {
return summaryBySlo;
const result = await this.esClient.search({
index: SLO_DESTINATION_INDEX_PATTERN,
size: 0,
query: {
bool: {
filter: [
{ term: { 'slo.id': slo.id } },
{ term: { 'slo.revision': slo.revision } },
{
range: {
'@timestamp': { gte: dateRange.from.toISOString(), lt: dateRange.to.toISOString() },
},
},
...extraInstanceIdFilter,
],
},
},
...(occurrencesBudgetingMethodSchema.is(slo.budgetingMethod) && {
aggs: {
good: { sum: { field: 'slo.numerator' } },
total: { sum: { field: 'slo.denominator' } },
},
}),
...(timeslicesBudgetingMethodSchema.is(slo.budgetingMethod) && {
aggs: {
good: {
sum: { field: 'slo.isGoodSlice' },
},
total: {
value_count: { field: 'slo.isGoodSlice' },
},
},
}),
});
// @ts-ignore value is not type correctly
const good = result.aggregations?.good?.value ?? 0;
// @ts-ignore value is not type correctly
const total = result.aggregations?.total?.value ?? 0;
const sliValue = computeSLI(good, total);
const initialErrorBudget = 1 - slo.objective.target;
let errorBudget;
if (
calendarAlignedTimeWindowSchema.is(slo.timeWindow) &&
timeslicesBudgetingMethodSchema.is(slo.budgetingMethod)
) {
const totalSlices = computeTotalSlicesFromDateRange(
dateRange,
slo.objective.timesliceWindow!
);
const consumedErrorBudget =
sliValue < 0 ? 0 : (total - good) / (totalSlices * initialErrorBudget);
errorBudget = toErrorBudget(initialErrorBudget, consumedErrorBudget);
} else {
const consumedErrorBudget = sliValue < 0 ? 0 : (1 - sliValue) / initialErrorBudget;
errorBudget = toErrorBudget(
initialErrorBudget,
consumedErrorBudget,
calendarAlignedTimeWindowSchema.is(slo.timeWindow)
);
}
const result = await this.esClient.msearch({ searches });
for (let i = 0; i < result.responses.length; i++) {
const slo = sloList[i];
// @ts-ignore
const { aggregations = {} } = result.responses[i];
const good = aggregations?.good?.value ?? 0;
const total = aggregations?.total?.value ?? 0;
const sliValue = computeSLI(good, total);
const initialErrorBudget = 1 - slo.objective.target;
let errorBudget;
if (
calendarAlignedTimeWindowSchema.is(slo.timeWindow) &&
timeslicesBudgetingMethodSchema.is(slo.budgetingMethod)
) {
const totalSlices = computeTotalSlicesFromDateRange(
dateRangeBySlo[slo.id],
slo.objective.timesliceWindow!
);
const consumedErrorBudget =
sliValue < 0 ? 0 : (total - good) / (totalSlices * initialErrorBudget);
errorBudget = toErrorBudget(initialErrorBudget, consumedErrorBudget);
} else {
const consumedErrorBudget = sliValue < 0 ? 0 : (1 - sliValue) / initialErrorBudget;
errorBudget = toErrorBudget(
initialErrorBudget,
consumedErrorBudget,
calendarAlignedTimeWindowSchema.is(slo.timeWindow)
);
}
summaryBySlo[slo.id] = {
sliValue,
errorBudget,
status: computeSummaryStatus(slo, sliValue, errorBudget),
};
}
return summaryBySlo;
return {
sliValue,
errorBudget,
status: computeSummaryStatus(slo, sliValue, errorBudget),
};
}
}
@ -95,38 +116,3 @@ function computeTotalSlicesFromDateRange(dateRange: DateRange, timesliceWindow:
);
return Math.ceil(dateRangeDurationInUnit / timesliceWindow!.value);
}
function generateSearchQuery(slo: SLO, dateRange: DateRange): MsearchMultisearchBody {
return {
size: 0,
query: {
bool: {
filter: [
{ term: { 'slo.id': slo.id } },
{ term: { 'slo.revision': slo.revision } },
{
range: {
'@timestamp': { gte: dateRange.from.toISOString(), lt: dateRange.to.toISOString() },
},
},
],
},
},
...(occurrencesBudgetingMethodSchema.is(slo.budgetingMethod) && {
aggs: {
good: { sum: { field: 'slo.numerator' } },
total: { sum: { field: 'slo.denominator' } },
},
}),
...(timeslicesBudgetingMethodSchema.is(slo.budgetingMethod) && {
aggs: {
good: {
sum: { field: 'slo.isGoodSlice' },
},
total: {
value_count: { field: 'slo.isGoodSlice' },
},
},
}),
};
}

View file

@ -6,6 +6,7 @@
*/
import { ElasticsearchClient, Logger } from '@kbn/core/server';
import { ALL_VALUE } from '@kbn/slo-schema';
import { assertNever } from '@kbn/std';
import _ from 'lodash';
import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from '../../assets/constants';
@ -17,6 +18,7 @@ interface EsSummaryDocument {
slo: {
id: string;
revision: number;
instanceId: string;
};
sliValue: number;
errorBudgetConsumed: number;
@ -37,6 +39,7 @@ export interface Paginated<T> {
export interface SLOSummary {
id: SLOId;
instanceId: string;
summary: Summary;
}
@ -122,6 +125,7 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
page: pagination.page,
results: finalResults.map((doc) => ({
id: doc._source!.slo.id,
instanceId: doc._source?.slo.instanceId ?? ALL_VALUE,
summary: {
errorBudget: {
initial: toHighPrecision(doc._source!.errorBudgetInitial),

View file

@ -112,6 +112,11 @@ Array [
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -335,6 +340,11 @@ Array [
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -558,6 +568,11 @@ Array [
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -794,6 +809,11 @@ Array [
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -1045,6 +1065,11 @@ Array [
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { ALL_VALUE } from '@kbn/slo-schema';
import { SLO } from '../../../../domain/models';
export function createTempSummaryDocument(slo: SLO) {
@ -25,7 +26,8 @@ export function createTempSummaryDocument(slo: SLO) {
duration: slo.timeWindow.duration.format(),
type: slo.timeWindow.type,
},
instanceId: '*',
groupBy: !!slo.groupBy ? slo.groupBy : ALL_VALUE,
instanceId: ALL_VALUE,
name: slo.name,
description: slo.description,
id: slo.id,

View file

@ -16,6 +16,11 @@ export const groupBy = {
field: 'slo.revision',
},
},
'slo.groupBy': {
terms: {
field: 'slo.groupBy',
},
},
'slo.instanceId': {
terms: {
field: 'slo.instanceId',

View file

@ -200,6 +200,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -309,6 +314,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -413,6 +423,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -522,6 +537,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -650,6 +670,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -780,6 +805,12 @@ Object {
},
"type": "keyword",
},
"slo.groupBy": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
@ -918,6 +949,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -1043,6 +1079,12 @@ Object {
},
"type": "keyword",
},
"slo.groupBy": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,

View file

@ -188,6 +188,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -293,6 +298,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -393,6 +403,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -498,6 +513,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -619,6 +639,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -745,6 +770,12 @@ Object {
},
"type": "keyword",
},
"slo.groupBy": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
@ -876,6 +907,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -997,6 +1033,12 @@ Object {
},
"type": "keyword",
},
"slo.groupBy": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,

View file

@ -148,6 +148,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -230,6 +235,12 @@ Object {
},
"type": "keyword",
},
"slo.groupBy": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
@ -382,6 +393,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -459,6 +475,12 @@ Object {
},
"type": "keyword",
},
"slo.groupBy": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,

View file

@ -163,6 +163,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -245,6 +250,12 @@ Object {
},
"type": "keyword",
},
"slo.groupBy": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
@ -371,6 +382,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -448,6 +464,12 @@ Object {
},
"type": "keyword",
},
"slo.groupBy": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,

View file

@ -172,6 +172,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -254,6 +259,12 @@ Object {
},
"type": "keyword",
},
"slo.groupBy": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
@ -418,6 +429,11 @@ Object {
"field": "slo.description",
},
},
"slo.groupBy": Object {
"terms": Object {
"field": "slo.groupBy",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
@ -495,6 +511,12 @@ Object {
},
"type": "keyword",
},
"slo.groupBy": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { ALL_VALUE } from '@kbn/slo-schema';
import {
createAPMTransactionDurationIndicator,
createSLO,
@ -47,10 +48,10 @@ describe('APM Transaction Duration Transform Generator', () => {
it("does not include the query filter when params are '*'", () => {
const slo = createSLO({
indicator: createAPMTransactionDurationIndicator({
environment: '*',
service: '*',
transactionName: '*',
transactionType: '*',
environment: ALL_VALUE,
service: ALL_VALUE,
transactionName: ALL_VALUE,
transactionType: ALL_VALUE,
}),
});
const transform = generator.getTransformParams(slo);
@ -86,9 +87,9 @@ describe('APM Transaction Duration Transform Generator', () => {
const slo = createSLO({
indicator: createAPMTransactionDurationIndicator({
service: 'my-service',
environment: '*',
transactionName: '*',
transactionType: '*',
environment: ALL_VALUE,
transactionName: ALL_VALUE,
transactionType: ALL_VALUE,
}),
});
@ -101,10 +102,10 @@ describe('APM Transaction Duration Transform Generator', () => {
it("groups by the 'service.environment'", () => {
const slo = createSLO({
indicator: createAPMTransactionDurationIndicator({
service: '*',
service: ALL_VALUE,
environment: 'production',
transactionName: '*',
transactionType: '*',
transactionName: ALL_VALUE,
transactionType: ALL_VALUE,
}),
});
@ -117,10 +118,10 @@ describe('APM Transaction Duration Transform Generator', () => {
it("groups by the 'transaction.name'", () => {
const slo = createSLO({
indicator: createAPMTransactionDurationIndicator({
service: '*',
environment: '*',
service: ALL_VALUE,
environment: ALL_VALUE,
transactionName: 'GET /foo',
transactionType: '*',
transactionType: ALL_VALUE,
}),
});
@ -133,9 +134,9 @@ describe('APM Transaction Duration Transform Generator', () => {
it("groups by the 'transaction.type'", () => {
const slo = createSLO({
indicator: createAPMTransactionDurationIndicator({
service: '*',
environment: '*',
transactionName: '*',
service: ALL_VALUE,
environment: ALL_VALUE,
transactionName: ALL_VALUE,
transactionType: 'request',
}),
});

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { ALL_VALUE } from '@kbn/slo-schema';
import {
createAPMTransactionErrorRateIndicator,
createSLO,
@ -47,10 +48,10 @@ describe('APM Transaction Error Rate Transform Generator', () => {
it("does not include the query filter when params are '*'", async () => {
const slo = createSLO({
indicator: createAPMTransactionErrorRateIndicator({
environment: '*',
service: '*',
transactionName: '*',
transactionType: '*',
environment: ALL_VALUE,
service: ALL_VALUE,
transactionName: ALL_VALUE,
transactionType: ALL_VALUE,
}),
});
const transform = generator.getTransformParams(slo);
@ -86,9 +87,9 @@ describe('APM Transaction Error Rate Transform Generator', () => {
const slo = createSLO({
indicator: createAPMTransactionErrorRateIndicator({
service: 'my-service',
environment: '*',
transactionName: '*',
transactionType: '*',
environment: ALL_VALUE,
transactionName: ALL_VALUE,
transactionType: ALL_VALUE,
}),
});
@ -101,10 +102,10 @@ describe('APM Transaction Error Rate Transform Generator', () => {
it("groups by the 'service.environment'", () => {
const slo = createSLO({
indicator: createAPMTransactionErrorRateIndicator({
service: '*',
service: ALL_VALUE,
environment: 'production',
transactionName: '*',
transactionType: '*',
transactionName: ALL_VALUE,
transactionType: ALL_VALUE,
}),
});
@ -117,10 +118,10 @@ describe('APM Transaction Error Rate Transform Generator', () => {
it("groups by the 'transaction.name'", () => {
const slo = createSLO({
indicator: createAPMTransactionErrorRateIndicator({
service: '*',
environment: '*',
service: ALL_VALUE,
environment: ALL_VALUE,
transactionName: 'GET /foo',
transactionType: '*',
transactionType: ALL_VALUE,
}),
});
@ -133,9 +134,9 @@ describe('APM Transaction Error Rate Transform Generator', () => {
it("groups by the 'transaction.type'", () => {
const slo = createSLO({
indicator: createAPMTransactionErrorRateIndicator({
service: '*',
environment: '*',
transactionName: '*',
service: ALL_VALUE,
environment: ALL_VALUE,
transactionName: ALL_VALUE,
transactionType: 'request',
}),
});

View file

@ -17,6 +17,8 @@ export abstract class TransformGenerator {
public abstract getTransformParams(slo: SLO): TransformPutTransformRequest;
public buildCommonRuntimeMappings(slo: SLO): MappingRuntimeFields {
const mustIncludeAllInstanceId = slo.groupBy === ALL_VALUE || slo.groupBy === '';
return {
'slo.id': {
type: 'keyword',
@ -30,12 +32,20 @@ export abstract class TransformGenerator {
source: `emit(${slo.revision})`,
},
},
'slo.instanceId': {
'slo.groupBy': {
type: 'keyword',
script: {
source: `emit('${ALL_VALUE}')`,
source: `emit('${!!slo.groupBy ? slo.groupBy : ALL_VALUE}')`,
},
},
...(mustIncludeAllInstanceId && {
'slo.instanceId': {
type: 'keyword',
script: {
source: `emit('${ALL_VALUE}')`,
},
},
}),
'slo.name': {
type: 'keyword',
script: {
@ -109,10 +119,14 @@ export abstract class TransformGenerator {
fixedInterval = slo.objective.timesliceWindow!.format();
}
const instanceIdField =
slo.groupBy !== '' && slo.groupBy !== ALL_VALUE ? slo.groupBy : 'slo.instanceId';
return {
'slo.id': { terms: { field: 'slo.id' } },
'slo.revision': { terms: { field: 'slo.revision' } },
'slo.instanceId': { terms: { field: 'slo.instanceId' } },
'slo.groupBy': { terms: { field: 'slo.groupBy' } },
'slo.instanceId': { terms: { field: instanceIdField } },
'slo.name': { terms: { field: 'slo.name' } },
'slo.description': { terms: { field: 'slo.description' } },
'slo.tags': { terms: { field: 'slo.tags' } },

View file

@ -31,6 +31,7 @@ export class UpdateSLO {
const updatedSlo: SLO = Object.assign({}, originalSlo, params, {
updatedAt: new Date(),
revision: originalSlo.revision + 1,
groupBy: !!params.groupBy ? params.groupBy : originalSlo.groupBy,
});
validateSLO(updatedSlo);