feat(slo): introduce new search capabilities (#162665)

This commit is contained in:
Kevin Delemme 2023-07-31 20:13:38 -04:00 committed by GitHub
parent 1700e3e534
commit 757c881b9a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
104 changed files with 7405 additions and 1429 deletions

View file

@ -27,8 +27,6 @@ describe('Duration', () => {
expect(new Duration(1, DurationUnit.Day).format()).toBe('1d');
expect(new Duration(1, DurationUnit.Week).format()).toBe('1w');
expect(new Duration(1, DurationUnit.Month).format()).toBe('1M');
expect(new Duration(1, DurationUnit.Quarter).format()).toBe('1Q');
expect(new Duration(1, DurationUnit.Year).format()).toBe('1Y');
});
});
@ -39,31 +37,25 @@ describe('Duration', () => {
expect(short.isShorterThan(new Duration(1, DurationUnit.Day))).toBe(true);
expect(short.isShorterThan(new Duration(1, DurationUnit.Week))).toBe(true);
expect(short.isShorterThan(new Duration(1, DurationUnit.Month))).toBe(true);
expect(short.isShorterThan(new Duration(1, DurationUnit.Quarter))).toBe(true);
expect(short.isShorterThan(new Duration(1, DurationUnit.Year))).toBe(true);
});
it('returns false when the current duration is longer (or equal) than the other duration', () => {
const long = new Duration(1, DurationUnit.Year);
const long = new Duration(1, DurationUnit.Month);
expect(long.isShorterThan(new Duration(1, DurationUnit.Minute))).toBe(false);
expect(long.isShorterThan(new Duration(1, DurationUnit.Hour))).toBe(false);
expect(long.isShorterThan(new Duration(1, DurationUnit.Day))).toBe(false);
expect(long.isShorterThan(new Duration(1, DurationUnit.Week))).toBe(false);
expect(long.isShorterThan(new Duration(1, DurationUnit.Month))).toBe(false);
expect(long.isShorterThan(new Duration(1, DurationUnit.Quarter))).toBe(false);
expect(long.isShorterThan(new Duration(1, DurationUnit.Year))).toBe(false);
});
});
describe('isLongerOrEqualThan', () => {
it('returns true when the current duration is longer or equal than the other duration', () => {
const long = new Duration(2, DurationUnit.Year);
const long = new Duration(2, DurationUnit.Month);
expect(long.isLongerOrEqualThan(new Duration(1, DurationUnit.Hour))).toBe(true);
expect(long.isLongerOrEqualThan(new Duration(1, DurationUnit.Day))).toBe(true);
expect(long.isLongerOrEqualThan(new Duration(1, DurationUnit.Week))).toBe(true);
expect(long.isLongerOrEqualThan(new Duration(1, DurationUnit.Month))).toBe(true);
expect(long.isLongerOrEqualThan(new Duration(1, DurationUnit.Quarter))).toBe(true);
expect(long.isLongerOrEqualThan(new Duration(1, DurationUnit.Year))).toBe(true);
});
it('returns false when the current duration is shorter than the other duration', () => {
@ -73,8 +65,6 @@ describe('Duration', () => {
expect(short.isLongerOrEqualThan(new Duration(1, DurationUnit.Day))).toBe(false);
expect(short.isLongerOrEqualThan(new Duration(1, DurationUnit.Week))).toBe(false);
expect(short.isLongerOrEqualThan(new Duration(1, DurationUnit.Month))).toBe(false);
expect(short.isLongerOrEqualThan(new Duration(1, DurationUnit.Quarter))).toBe(false);
expect(short.isLongerOrEqualThan(new Duration(1, DurationUnit.Year))).toBe(false);
});
});

View file

@ -14,8 +14,6 @@ enum DurationUnit {
'Day' = 'd',
'Week' = 'w',
'Month' = 'M',
'Quarter' = 'Q',
'Year' = 'Y',
}
class Duration {
@ -55,6 +53,10 @@ class Duration {
format(): string {
return `${this.value}${this.unit}`;
}
asSeconds(): number {
return moment.duration(this.value, toMomentUnitOfTime(this.unit)).asSeconds();
}
}
const toDurationUnit = (unit: string): DurationUnit => {
@ -69,10 +71,6 @@ const toDurationUnit = (unit: string): DurationUnit => {
return DurationUnit.Week;
case 'M':
return DurationUnit.Month;
case 'Q':
return DurationUnit.Quarter;
case 'y':
return DurationUnit.Year;
default:
throw new Error('invalid duration unit');
}
@ -90,10 +88,6 @@ const toMomentUnitOfTime = (unit: DurationUnit): moment.unitOfTime.Diff => {
return 'weeks';
case DurationUnit.Month:
return 'months';
case DurationUnit.Quarter:
return 'quarters';
case DurationUnit.Year:
return 'years';
default:
assertNever(unit);
}

View file

@ -7,15 +7,17 @@
import * as t from 'io-ts';
import {
apmTransactionDurationIndicatorSchema,
apmTransactionErrorRateIndicatorSchema,
budgetingMethodSchema,
dateType,
durationType,
histogramIndicatorSchema,
historicalSummarySchema,
indicatorSchema,
indicatorTypesArraySchema,
indicatorTypesSchema,
kqlCustomIndicatorSchema,
metricCustomIndicatorSchema,
histogramIndicatorSchema,
objectiveSchema,
optionalSettingsSchema,
previewDataSchema,
@ -24,9 +26,6 @@ import {
summarySchema,
tagsSchema,
timeWindowSchema,
apmTransactionErrorRateIndicatorSchema,
apmTransactionDurationIndicatorSchema,
durationType,
timeWindowTypeSchema,
} from '../schema';
@ -69,12 +68,16 @@ const getSLOParamsSchema = t.type({
});
const sortDirectionSchema = t.union([t.literal('asc'), t.literal('desc')]);
const sortBySchema = t.union([t.literal('creationTime'), t.literal('indicatorType')]);
const sortBySchema = t.union([
t.literal('error_budget_consumed'),
t.literal('error_budget_remaining'),
t.literal('sli_value'),
t.literal('status'),
]);
const findSLOParamsSchema = t.partial({
query: t.partial({
name: t.string,
indicatorTypes: indicatorTypesArraySchema,
kqlQuery: t.string,
page: t.string,
perPage: t.string,
sortBy: sortBySchema,

View file

@ -43,8 +43,6 @@ const summarySchema = t.type({
errorBudget: errorBudgetSchema,
});
type SummarySchema = t.TypeOf<typeof summarySchema>;
const historicalSummarySchema = t.intersection([
t.type({
date: dateType,
@ -59,8 +57,6 @@ const previewDataSchema = t.type({
const dateRangeSchema = t.type({ from: dateType, to: dateType });
export type { SummarySchema };
export {
ALL_VALUE,
allOrAnyString,

View file

@ -136,27 +136,13 @@
"$ref": "#/components/parameters/space_id"
},
{
"name": "name",
"name": "kqlQuery",
"in": "query",
"description": "Filter by name",
"description": "A valid kql query to filter the SLO with",
"schema": {
"type": "string"
},
"example": "awesome-service"
},
{
"name": "indicatorTypes",
"in": "query",
"description": "Filter by indicator type",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"example": [
"sli.kql.custom"
]
"example": "slo.name:latency* and slo.tags : \"prod\""
},
{
"name": "page",
@ -176,7 +162,7 @@
"type": "integer",
"default": 25
},
"example": 20
"example": 25
},
{
"name": "sortBy",
@ -185,12 +171,14 @@
"schema": {
"type": "string",
"enum": [
"creationTime",
"indicatorType"
"sli_value",
"status",
"error_budget_consumed",
"error_budget_remaining"
],
"default": "creationTime"
"default": "status"
},
"example": "creationTime"
"example": "status"
},
{
"name": "sortDirection",
@ -1333,7 +1321,8 @@
"sli.apm.transactionErrorRate": "#/components/schemas/indicator_properties_apm_availability",
"sli.kql.custom": "#/components/schemas/indicator_properties_custom_kql",
"sli.apm.transactionDuration": "#/components/schemas/indicator_properties_apm_latency",
"sli.apm.sli.metric.custom": "#/components/schemas/indicator_properties_custom_metric"
"sli.metric.custom": "#/components/schemas/indicator_properties_custom_metric",
"sli.histogram.custom": "#/components/schemas/indicator_properties_histogram"
}
},
"oneOf": [

View file

@ -80,21 +80,12 @@ paths:
parameters:
- $ref: '#/components/parameters/kbn_xsrf'
- $ref: '#/components/parameters/space_id'
- name: name
- name: kqlQuery
in: query
description: Filter by name
description: A valid kql query to filter the SLO with
schema:
type: string
example: awesome-service
- name: indicatorTypes
in: query
description: Filter by indicator type
schema:
type: array
items:
type: string
example:
- sli.kql.custom
example: 'slo.name:latency* and slo.tags : "prod"'
- name: page
in: query
description: The page number to return
@ -108,17 +99,19 @@ paths:
schema:
type: integer
default: 25
example: 20
example: 25
- name: sortBy
in: query
description: Sort by field
schema:
type: string
enum:
- creationTime
- indicatorType
default: creationTime
example: creationTime
- sli_value
- status
- error_budget_consumed
- error_budget_remaining
default: status
example: status
- name: sortDirection
in: query
description: Sort order
@ -920,7 +913,8 @@ components:
sli.apm.transactionErrorRate: '#/components/schemas/indicator_properties_apm_availability'
sli.kql.custom: '#/components/schemas/indicator_properties_custom_kql'
sli.apm.transactionDuration: '#/components/schemas/indicator_properties_apm_latency'
sli.apm.sli.metric.custom: '#/components/schemas/indicator_properties_custom_metric'
sli.metric.custom: '#/components/schemas/indicator_properties_custom_metric'
sli.histogram.custom: '#/components/schemas/indicator_properties_histogram'
oneOf:
- $ref: '#/components/schemas/indicator_properties_custom_kql'
- $ref: '#/components/schemas/indicator_properties_apm_availability'

View file

@ -60,20 +60,12 @@ get:
parameters:
- $ref: ../components/headers/kbn_xsrf.yaml
- $ref: ../components/parameters/space_id.yaml
- name: name
- name: kqlQuery
in: query
description: Filter by name
description: A valid kql query to filter the SLO with
schema:
type: string
example: awesome-service
- name: indicatorTypes
in: query
description: Filter by indicator type
schema:
type: array
items:
type: string
example: ['sli.kql.custom']
example: 'slo.name:latency* and slo.tags : "prod"'
- name: page
in: query
description: The page number to return
@ -87,15 +79,15 @@ get:
schema:
type: integer
default: 25
example: 20
example: 25
- name: sortBy
in: query
description: Sort by field
schema:
type: string
enum: [creationTime, indicatorType]
default: creationTime
example: creationTime
enum: [sli_value, status, error_budget_consumed, error_budget_remaining]
default: status
example: status
- name: sortDirection
in: query
description: Sort order

View file

@ -30,7 +30,7 @@ describe('SLO Selector', () => {
render(<SloSelector onSelected={onSelectedSpy} />);
expect(screen.getByTestId('sloSelector')).toBeTruthy();
expect(useFetchSloListMock).toHaveBeenCalledWith({ name: '' });
expect(useFetchSloListMock).toHaveBeenCalledWith({ kqlQuery: 'slo.name:*' });
});
it('searches SLOs when typing', async () => {
@ -42,6 +42,6 @@ describe('SLO Selector', () => {
await wait(310); // debounce delay
});
expect(useFetchSloListMock).toHaveBeenCalledWith({ name: 'latency' });
expect(useFetchSloListMock).toHaveBeenCalledWith({ kqlQuery: 'slo.name:latency*' });
});
});

View file

@ -23,7 +23,7 @@ function SloSelector({ initialSlo, onSelected, errors }: Props) {
const [options, setOptions] = useState<Array<EuiComboBoxOptionOption<string>>>([]);
const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>();
const [searchValue, setSearchValue] = useState<string>('');
const { isLoading, sloList } = useFetchSloList({ name: searchValue });
const { isLoading, sloList } = useFetchSloList({ kqlQuery: `slo.name:${searchValue}*` });
const hasError = errors !== undefined && errors.length > 0;
useEffect(() => {

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { EuiBadge, EuiFlexItem } from '@elastic/eui';
import { EuiBadge, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
@ -19,11 +19,18 @@ export function SloStatusBadge({ slo }: SloStatusProps) {
<>
<EuiFlexItem grow={false}>
{slo.summary.status === 'NO_DATA' && (
<EuiBadge color="default">
{i18n.translate('xpack.observability.slo.sloStatusBadge.noData', {
defaultMessage: 'No data',
<EuiToolTip
position="top"
content={i18n.translate('xpack.observability.slo.sloStatusBadge.noDataTooltip', {
defaultMessage: 'It may take some time before the data is aggregated and available.',
})}
</EuiBadge>
>
<EuiBadge color="default">
{i18n.translate('xpack.observability.slo.sloStatusBadge.noData', {
defaultMessage: 'No data',
})}
</EuiBadge>
</EuiToolTip>
)}
{slo.summary.status === 'HEALTHY' && (

View file

@ -8,10 +8,10 @@
import type { Indicator } from '@kbn/slo-schema';
interface SloListFilter {
name: string;
kqlQuery: string;
page: number;
sortBy: string;
indicatorTypes: string[];
sortDirection: string;
}
interface CompositeSloKeyFilter {

View file

@ -77,9 +77,6 @@ export function useCloneSlo() {
})
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: sloKeys.lists(), exact: false });
},
}
);
}

View file

@ -44,7 +44,7 @@ export function useCreateSlo() {
const [queryKey, previousData] = queriesData?.at(0) ?? [];
const newItem = { ...slo, id: uuidv1() };
const newItem = { ...slo, id: uuidv1(), summary: undefined };
const optimisticUpdate = {
page: previousData?.page ?? 1,
@ -83,9 +83,6 @@ export function useCreateSlo() {
http.basePath.prepend(paths.observability.sloCreateWithEncodedForm(encode(slo)))
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: sloKeys.lists(), exact: false });
},
}
);
}

View file

@ -76,9 +76,6 @@ export function useDeleteSlo() {
})
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: sloKeys.lists(), exact: false });
},
}
);
}

View file

@ -20,10 +20,10 @@ import { useKibana } from '../../utils/kibana_react';
import { sloKeys } from './query_key_factory';
interface SLOListParams {
name?: string;
kqlQuery?: string;
page?: number;
sortBy?: string;
indicatorTypes?: string[];
sortDirection?: 'asc' | 'desc';
shouldRefetch?: boolean;
}
@ -43,10 +43,10 @@ const SHORT_REFETCH_INTERVAL = 1000 * 5; // 5 seconds
const LONG_REFETCH_INTERVAL = 1000 * 60; // 1 minute
export function useFetchSloList({
name = '',
kqlQuery = '',
page = 1,
sortBy = 'creationTime',
indicatorTypes = [],
sortBy = 'status',
sortDirection = 'desc',
shouldRefetch,
}: SLOListParams | undefined = {}): UseFetchSloListResponse {
const {
@ -61,18 +61,15 @@ export function useFetchSloList({
const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data, refetch } = useQuery(
{
queryKey: sloKeys.list({ name, page, sortBy, indicatorTypes }),
queryKey: sloKeys.list({ kqlQuery, page, sortBy, sortDirection }),
queryFn: async ({ signal }) => {
try {
const response = await http.get<FindSLOResponse>(`/api/observability/slos`, {
query: {
...(page && { page }),
...(name && { name }),
...(kqlQuery && { kqlQuery }),
...(sortBy && { sortBy }),
...(indicatorTypes &&
indicatorTypes.length > 0 && {
indicatorTypes: indicatorTypes.join(','),
}),
...(sortDirection && { sortDirection }),
...(page && { page }),
},
signal,
});

View file

@ -11,7 +11,7 @@ import { rollingTimeWindowTypeSchema, SLOWithSummaryResponse } from '@kbn/slo-sc
import { euiLightVars } from '@kbn/ui-theme';
import moment from 'moment';
import React from 'react';
import { toMomentUnitOfTime } from '../../../../utils/slo/duration';
import { toCalendarAlignedMomentUnitOfTime } from '../../../../utils/slo/duration';
import { toDurationLabel } from '../../../../utils/slo/labels';
export interface Props {
@ -34,11 +34,11 @@ export function SloTimeWindowBadge({ slo }: Props) {
);
}
const unitMoment = toMomentUnitOfTime(unit);
const unitMoment = toCalendarAlignedMomentUnitOfTime(unit);
const now = moment.utc();
const periodStart = now.clone().startOf(unitMoment!);
const periodEnd = now.clone().endOf(unitMoment!);
const periodStart = now.clone().startOf(unitMoment);
const periodEnd = now.clone().endOf(unitMoment);
const totalDurationInDays = periodEnd.diff(periodStart, 'days') + 1;
const elapsedDurationInDays = now.diff(periodStart, 'days') + 1;

View file

@ -5,18 +5,12 @@
* 2.0.
*/
import React, { useMemo, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiPagination } from '@elastic/eui';
import { debounce } from 'lodash';
import { useIsMutating } from '@tanstack/react-query';
import React, { useState } from 'react';
import { useFetchSloList } from '../../../hooks/slo/use_fetch_slo_list';
import {
FilterType,
SloListSearchFilterSortBar,
SortType,
} from './slo_list_search_filter_sort_bar';
import { SloListItems } from './slo_list_items';
import { SloListSearchFilterSortBar, SortField } from './slo_list_search_filter_sort_bar';
export interface Props {
autoRefresh: boolean;
@ -24,16 +18,14 @@ export interface Props {
export function SloList({ autoRefresh }: Props) {
const [activePage, setActivePage] = useState(0);
const [query, setQuery] = useState('');
const [sort, setSort] = useState<SortType>('creationTime');
const [indicatorTypeFilter, setIndicatorTypeFilter] = useState<FilterType[]>([]);
const [sort, setSort] = useState<SortField | undefined>('status');
const { isInitialLoading, isLoading, isRefetching, isError, sloList, refetch } = useFetchSloList({
page: activePage + 1,
name: query,
kqlQuery: query,
sortBy: sort,
indicatorTypes: indicatorTypeFilter,
sortDirection: 'desc',
shouldRefetch: autoRefresh,
});
@ -49,20 +41,16 @@ export function SloList({ autoRefresh }: Props) {
refetch();
};
const handleChangeQuery = useMemo(
() =>
debounce((e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
}, 300),
[]
);
const handleChangeSort = (newSort: SortType) => {
setSort(newSort);
const handleChangeQuery = (newQuery: string) => {
setActivePage(0);
setQuery(newQuery);
refetch();
};
const handleChangeIndicatorTypeFilter = (newFilter: FilterType[]) => {
setIndicatorTypeFilter(newFilter);
const handleChangeSort = (newSort: SortField | undefined) => {
setActivePage(0);
setSort(newSort);
refetch();
};
return (
@ -80,7 +68,6 @@ export function SloList({ autoRefresh }: Props) {
}
onChangeQuery={handleChangeQuery}
onChangeSort={handleChangeSort}
onChangeIndicatorTypeFilter={handleChangeIndicatorTypeFilter}
/>
</EuiFlexItem>

View file

@ -5,8 +5,6 @@
* 2.0.
*/
import React, { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
EuiButtonIcon,
EuiContextMenuItem,
@ -19,27 +17,29 @@ import {
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { 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';
import { rulesLocatorID, sloFeatureId } from '../../../../common';
import { SLO_BURN_RATE_RULE_TYPE_ID } from '../../../../common/constants';
import { sloKeys } from '../../../hooks/slo/query_key_factory';
import { useCapabilities } from '../../../hooks/slo/use_capabilities';
import { useKibana } from '../../../utils/kibana_react';
import { useCloneSlo } from '../../../hooks/slo/use_clone_slo';
import { useGetFilteredRuleTypes } from '../../../hooks/use_get_filtered_rule_types';
import { SloSummary } from './slo_summary';
import { SloDeleteConfirmationModal } from './slo_delete_confirmation_modal';
import { SloBadges } from './badges/slo_badges';
import {
transformSloResponseToCreateSloForm,
transformCreateSLOFormToCreateSLOInput,
} from '../../slo_edit/helpers/process_slo_form_values';
import { SLO_BURN_RATE_RULE_TYPE_ID } from '../../../../common/constants';
import { rulesLocatorID, sloFeatureId } from '../../../../common';
import { paths } from '../../../routes/paths';
import { useDeleteSlo } from '../../../hooks/slo/use_delete_slo';
import type { ActiveAlerts } from '../../../hooks/slo/use_fetch_active_alerts';
import type { SloRule } from '../../../hooks/slo/use_fetch_rules_for_slo';
import { useGetFilteredRuleTypes } from '../../../hooks/use_get_filtered_rule_types';
import type { RulesParams } from '../../../locators/rules';
import { paths } from '../../../routes/paths';
import { useKibana } from '../../../utils/kibana_react';
import {
transformCreateSLOFormToCreateSLOInput,
transformSloResponseToCreateSloForm,
} from '../../slo_edit/helpers/process_slo_form_values';
import { SloBadges } from './badges/slo_badges';
import { SloDeleteConfirmationModal } from './slo_delete_confirmation_modal';
import { SloSummary } from './slo_summary';
export interface SloListItemProps {
slo: SLOWithSummaryResponse;
@ -47,7 +47,6 @@ export interface SloListItemProps {
historicalSummary?: HistoricalSummaryResponse[];
historicalSummaryLoading: boolean;
activeAlerts?: ActiveAlerts;
onConfirmDelete: (slo: SLOWithSummaryResponse) => void;
}
export function SloListItem({
@ -56,7 +55,6 @@ export function SloListItem({
historicalSummary = [],
historicalSummaryLoading,
activeAlerts,
onConfirmDelete,
}: SloListItemProps) {
const {
application: { navigateToUrl },
@ -72,6 +70,7 @@ export function SloListItem({
const filteredRuleTypes = useGetFilteredRuleTypes();
const { mutate: cloneSlo } = useCloneSlo();
const { mutate: deleteSlo } = useDeleteSlo();
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false);
const [isAddRuleFlyoutOpen, setIsAddRuleFlyoutOpen] = useState(false);
@ -100,15 +99,7 @@ export function SloListItem({
const handleNavigateToRules = async () => {
const locator = locators.get<RulesParams>(rulesLocatorID);
locator?.navigate(
{
params: { sloId: slo.id },
},
{
replace: false,
}
);
locator?.navigate({ params: { sloId: slo.id } }, { replace: false });
};
const handleClone = () => {
@ -127,7 +118,7 @@ export function SloListItem({
const handleDeleteConfirm = () => {
setDeleteConfirmationModalOpen(false);
onConfirmDelete(slo);
deleteSlo({ id: slo.id, name: slo.name });
};
const handleDeleteCancel = () => {

View file

@ -4,17 +4,15 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { SLOWithSummaryResponse } from '@kbn/slo-schema';
import React from 'react';
import { useFetchActiveAlerts } from '../../../hooks/slo/use_fetch_active_alerts';
import { useFetchRulesForSlo } from '../../../hooks/slo/use_fetch_rules_for_slo';
import { useFetchHistoricalSummary } from '../../../hooks/slo/use_fetch_historical_summary';
import { useDeleteSlo } from '../../../hooks/slo/use_delete_slo';
import { SloListItem } from './slo_list_item';
import { useFetchRulesForSlo } from '../../../hooks/slo/use_fetch_rules_for_slo';
import { SloListEmpty } from './slo_list_empty';
import { SloListError } from './slo_list_error';
import { SloListItem } from './slo_list_item';
export interface Props {
sloList: SLOWithSummaryResponse[];
@ -30,8 +28,6 @@ export function SloListItems({ sloList, loading, error }: Props) {
const { isLoading: historicalSummaryLoading, data: historicalSummaryBySlo } =
useFetchHistoricalSummary({ sloIds });
const { mutate: deleteSlo } = useDeleteSlo();
if (!loading && !error && sloList.length === 0) {
return <SloListEmpty />;
}
@ -39,10 +35,6 @@ export function SloListItems({ sloList, loading, error }: Props) {
return <SloListError />;
}
const handleDelete = (slo: SLOWithSummaryResponse) => {
deleteSlo({ id: slo.id, name: slo.name });
};
return (
<EuiFlexGroup direction="column" gutterSize="s">
{sloList.map((slo) => (
@ -53,7 +45,6 @@ export function SloListItems({ sloList, loading, error }: Props) {
historicalSummary={historicalSummaryBySlo?.[slo.id]}
historicalSummaryLoading={historicalSummaryLoading}
slo={slo}
onConfirmDelete={handleDelete}
/>
</EuiFlexItem>
))}

View file

@ -28,7 +28,6 @@ const defaultProps: SloListSearchFilterSortBarProps = {
loading: false,
onChangeQuery: () => {},
onChangeSort: () => {},
onChangeIndicatorTypeFilter: () => {},
};
export const SloListSearchFilterSortBar = Template.bind({});

View file

@ -6,7 +6,6 @@
*/
import {
EuiFieldSearch,
EuiFilterButton,
EuiFilterGroup,
EuiFlexGroup,
@ -18,29 +17,18 @@ import {
} from '@elastic/eui';
import { EuiSelectableOptionCheckedType } from '@elastic/eui/src/components/selectable/selectable_option';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useState } from 'react';
import {
INDICATOR_APM_AVAILABILITY,
INDICATOR_APM_LATENCY,
INDICATOR_CUSTOM_KQL,
INDICATOR_CUSTOM_METRIC,
INDICATOR_HISTOGRAM,
} from '../../../utils/slo/labels';
import { QueryStringInput } from '@kbn/unified-search-plugin/public';
import React, { useState } from 'react';
import { useCreateDataView } from '../../../hooks/use_create_data_view';
import { useKibana } from '../../../utils/kibana_react';
export interface SloListSearchFilterSortBarProps {
loading: boolean;
onChangeQuery: (e: React.ChangeEvent<HTMLInputElement>) => void;
onChangeSort: (sort: SortType) => void;
onChangeIndicatorTypeFilter: (filter: FilterType[]) => void;
onChangeQuery: (query: string) => void;
onChangeSort: (sort: SortField | undefined) => void;
}
export type SortType = 'creationTime' | 'indicatorType';
export type FilterType =
| 'sli.apm.transactionDuration'
| 'sli.apm.transactionErrorRate'
| 'sli.kql.custom'
| 'sli.metric.custom'
| 'sli.histogram.custom';
export type SortField = 'sli_value' | 'error_budget_consumed' | 'error_budget_remaining' | 'status';
export type Item<T> = EuiSelectableOption & {
label: string;
@ -48,42 +36,31 @@ export type Item<T> = EuiSelectableOption & {
checked?: EuiSelectableOptionCheckedType;
};
const SORT_OPTIONS: Array<Item<SortType>> = [
const SORT_OPTIONS: Array<Item<SortField>> = [
{
label: i18n.translate('xpack.observability.slo.list.sortBy.creationTime', {
defaultMessage: 'Creation time',
label: i18n.translate('xpack.observability.slo.list.sortBy.sliValue', {
defaultMessage: 'SLI value',
}),
type: 'creationTime',
type: 'sli_value',
},
{
label: i18n.translate('xpack.observability.slo.list.sortBy.sloStatus', {
defaultMessage: 'SLO status',
}),
type: 'status',
checked: 'on',
},
{
label: i18n.translate('xpack.observability.slo.list.sortBy.indicatorType', {
defaultMessage: 'Indicator type',
label: i18n.translate('xpack.observability.slo.list.sortBy.errorBudgetConsumed', {
defaultMessage: 'Error budget consumed',
}),
type: 'indicatorType',
},
];
const INDICATOR_TYPE_OPTIONS: Array<Item<FilterType>> = [
{
label: INDICATOR_APM_LATENCY,
type: 'sli.apm.transactionDuration',
type: 'error_budget_consumed',
},
{
label: INDICATOR_APM_AVAILABILITY,
type: 'sli.apm.transactionErrorRate',
},
{
label: INDICATOR_CUSTOM_KQL,
type: 'sli.kql.custom',
},
{
label: INDICATOR_CUSTOM_METRIC,
type: 'sli.metric.custom',
},
{
label: INDICATOR_HISTOGRAM,
type: 'sli.histogram.custom',
label: i18n.translate('xpack.observability.slo.list.sortBy.errorBudgetRemaining', {
defaultMessage: 'Error budget remaining',
}),
type: 'error_budget_remaining',
},
];
@ -91,96 +68,60 @@ export function SloListSearchFilterSortBar({
loading,
onChangeQuery,
onChangeSort,
onChangeIndicatorTypeFilter,
}: SloListSearchFilterSortBarProps) {
const [isFilterPopoverOpen, setFilterPopoverOpen] = useState(false);
const [isSortPopoverOpen, setSortPopoverOpen] = useState(false);
const { data, dataViews, docLinks, http, notifications, storage, uiSettings, unifiedSearch } =
useKibana().services;
const { dataView } = useCreateDataView({ indexPatternString: '.slo-observability.summary-*' });
const [isSortPopoverOpen, setSortPopoverOpen] = useState(false);
const [sortOptions, setSortOptions] = useState(SORT_OPTIONS);
const [indicatorTypeOptions, setIndicatorTypeOptions] = useState(INDICATOR_TYPE_OPTIONS);
const [query, setQuery] = useState('');
const selectedSort = sortOptions.find((option) => option.checked === 'on');
const selectedIndicatorTypeFilter = indicatorTypeOptions.filter(
(option) => option.checked === 'on'
);
const handleToggleFilterButton = () => setFilterPopoverOpen(!isFilterPopoverOpen);
const handleToggleSortButton = () => setSortPopoverOpen(!isSortPopoverOpen);
const handleChangeSort = (newOptions: Array<Item<SortType>>) => {
const handleChangeSort = (newOptions: Array<Item<SortField>>) => {
setSortOptions(newOptions);
setSortPopoverOpen(false);
onChangeSort(newOptions.find((o) => o.checked)?.type);
};
const handleChangeIndicatorTypeOptions = (newOptions: Array<Item<FilterType>>) => {
setIndicatorTypeOptions(newOptions);
onChangeIndicatorTypeFilter(
newOptions.filter((option) => option.checked === 'on').map((option) => option.type)
);
};
useEffect(() => {
if (selectedSort?.type === 'creationTime' || selectedSort?.type === 'indicatorType') {
onChangeSort(selectedSort.type);
}
}, [onChangeSort, selectedSort]);
return (
<EuiFlexGroup direction="row" gutterSize="s">
<EuiFlexItem grow>
<EuiFieldSearch
data-test-subj="o11ySloListSearchFilterSortBarFieldSearch"
fullWidth
isLoading={loading}
onChange={onChangeQuery}
<QueryStringInput
appName="Observability"
bubbleSubmitEvent={false}
deps={{
data,
dataViews,
docLinks,
http,
notifications,
storage,
uiSettings,
unifiedSearch,
}}
disableAutoFocus
onSubmit={() => onChangeQuery(query)}
disableLanguageSwitcher
isDisabled={loading}
indexPatterns={dataView ? [dataView] : []}
placeholder={i18n.translate('xpack.observability.slo.list.search', {
defaultMessage: 'Search',
defaultMessage: 'Search your SLOs...',
})}
query={{ query: String(query), language: 'kuery' }}
size="s"
onChange={(value) => setQuery(String(value.query))}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: 200 }}>
<EuiFilterGroup>
<EuiPopover
button={
<EuiFilterButton
iconType="arrowDown"
onClick={handleToggleFilterButton}
isSelected={isFilterPopoverOpen}
numFilters={selectedIndicatorTypeFilter.length}
>
{i18n.translate('xpack.observability.slo.list.indicatorTypeFilter', {
defaultMessage: 'Indicator type',
})}
</EuiFilterButton>
}
isOpen={isFilterPopoverOpen}
closePopover={handleToggleFilterButton}
panelPaddingSize="none"
anchorPosition="downCenter"
>
<div style={{ width: 300 }}>
<EuiPopoverTitle paddingSize="s">
{i18n.translate('xpack.observability.slo.list.indicatorTypeFilter', {
defaultMessage: 'Indicator type',
})}
</EuiPopoverTitle>
<EuiSelectable<Item<FilterType>>
options={indicatorTypeOptions}
onChange={handleChangeIndicatorTypeOptions}
>
{(list) => list}
</EuiSelectable>
</div>
</EuiPopover>
</EuiFilterGroup>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: 200 }}>
<EuiFlexItem grow={true} style={{ maxWidth: 280 }}>
<EuiFilterGroup>
<EuiPopover
button={
<EuiFilterButton
disabled={loading}
iconType="arrowDown"
onClick={handleToggleSortButton}
isSelected={isSortPopoverOpen}
@ -202,10 +143,11 @@ export function SloListSearchFilterSortBar({
defaultMessage: 'Sort by',
})}
</EuiPopoverTitle>
<EuiSelectable<Item<SortType>>
<EuiSelectable<Item<SortField>>
singleSelection
options={sortOptions}
onChange={handleChangeSort}
isLoading={loading}
>
{(list) => list}
</EuiSelectable>

View file

@ -69,6 +69,20 @@ const mockKibana = () => {
services: {
application: { navigateToUrl: mockNavigate },
charts: chartPluginMock.createSetupContract(),
data: {
dataViews: {
find: jest.fn().mockReturnValue([]),
get: jest.fn().mockReturnValue([]),
},
},
dataViews: {
create: jest.fn().mockResolvedValue(42),
},
docLinks: {
links: {
query: {},
},
},
http: {
basePath: {
prepend: (url: string) => url,
@ -87,6 +101,9 @@ const mockKibana = () => {
},
},
},
storage: {
get: () => {},
},
triggersActionsUi: { getAddRuleFlyout: mockGetAddRuleFlyout },
uiSettings: {
get: (settings: string) => {
@ -95,6 +112,11 @@ const mockKibana = () => {
return '';
},
},
unifiedSearch: {
autocomplete: {
hasQuerySuggestions: () => {},
},
},
},
});
};

View file

@ -31,8 +31,7 @@ export function SlosPage() {
const { hasAtLeast } = useLicense();
const { isInitialLoading, isLoading, isError, sloList } = useFetchSloList();
const { total } = sloList || {};
const { total } = sloList || { total: 0 };
const [isAutoRefreshing, setIsAutoRefreshing] = useState<boolean>(true);

View file

@ -7,7 +7,7 @@
import { RuleTypeParams } from '@kbn/alerting-plugin/common';
type DurationUnit = 'm' | 'h' | 'd' | 'w' | 'M' | 'Y';
type DurationUnit = 'm' | 'h' | 'd' | 'w' | 'M';
interface Duration {
value: number;

View file

@ -28,24 +28,17 @@ export function toMinutes(duration: Duration) {
return duration.value * 7 * 24 * 60;
case 'M':
return duration.value * 30 * 24 * 60;
case 'Y':
return duration.value * 365 * 24 * 60;
default:
assertNever(duration.unit);
}
assertNever(duration.unit);
}
export function toMomentUnitOfTime(unit: string): moment.unitOfTime.Diff | undefined {
export function toCalendarAlignedMomentUnitOfTime(unit: string): moment.unitOfTime.StartOf {
switch (unit) {
case 'd':
return 'days';
default:
case 'w':
return 'weeks';
return 'isoWeek';
case 'M':
return 'months';
case 'Q':
return 'quarters';
case 'Y':
return 'years';
}
}

View file

@ -112,13 +112,6 @@ export function toDurationLabel(durationStr: string): string {
duration: duration.value,
},
});
case 'Y':
return i18n.translate('xpack.observability.slo.duration.year', {
defaultMessage: '{duration, plural, one {1 year} other {# years}}',
values: {
duration: duration.value,
},
});
}
}
@ -146,9 +139,5 @@ export function toDurationAdverbLabel(durationStr: string): string {
return i18n.translate('xpack.observability.slo.duration.monthly', {
defaultMessage: 'Monthly',
});
case 'Y':
return i18n.translate('xpack.observability.slo.duration.yearly', {
defaultMessage: 'Yearly',
});
}
}

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { SLO_RESOURCES_VERSION } from '../constants';
export const getSLOMappingsTemplate = (name: string) => ({
name,
template: {
@ -14,6 +16,31 @@ export const getSLOMappingsTemplate = (name: string) => ({
type: 'date',
format: 'date_optional_time||epoch_millis',
},
// APM service and transaction specific fields
service: {
properties: {
name: {
type: 'keyword',
ignore_above: 256,
},
environment: {
type: 'keyword',
ignore_above: 256,
},
},
},
transaction: {
properties: {
name: {
type: 'keyword',
ignore_above: 256,
},
type: {
type: 'keyword',
ignore_above: 256,
},
},
},
slo: {
properties: {
id: {
@ -23,6 +50,53 @@ export const getSLOMappingsTemplate = (name: string) => ({
revision: {
type: 'long',
},
instanceId: {
type: 'keyword',
ignore_above: 256,
},
name: {
type: 'keyword',
ignore_above: 256,
},
description: {
type: 'keyword',
ignore_above: 256,
},
tags: {
type: 'keyword',
ignore_above: 256,
},
indicator: {
properties: {
type: {
type: 'keyword',
ignore_above: 256,
},
},
},
objective: {
properties: {
target: {
type: 'double',
},
sliceDurationInSeconds: {
type: 'long',
},
},
},
budgetingMethod: {
type: 'keyword',
},
timeWindow: {
properties: {
duration: {
type: 'keyword',
},
type: {
type: 'keyword',
},
},
},
numerator: {
type: 'long',
},
@ -32,9 +106,6 @@ export const getSLOMappingsTemplate = (name: string) => ({
isGoodSlice: {
type: 'byte',
},
context: {
type: 'flattened',
},
},
},
},
@ -42,7 +113,7 @@ export const getSLOMappingsTemplate = (name: string) => ({
},
_meta: {
description: 'Mappings for SLO rollup data',
version: 1,
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { SLO_RESOURCES_VERSION } from '../constants';
export const getSLOSettingsTemplate = (name: string) => ({
name,
template: {
@ -14,7 +16,7 @@ export const getSLOSettingsTemplate = (name: string) => ({
},
_meta: {
description: 'Settings for SLO rollup data',
version: 1,
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},

View file

@ -0,0 +1,128 @@
/*
* 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 { SLO_RESOURCES_VERSION } from '../constants';
export const getSLOSummaryMappingsTemplate = (name: string) => ({
name,
template: {
mappings: {
properties: {
// APM service and transaction specific fields
service: {
properties: {
name: {
type: 'keyword',
ignore_above: 256,
},
environment: {
type: 'keyword',
ignore_above: 256,
},
},
},
transaction: {
properties: {
name: {
type: 'keyword',
ignore_above: 256,
},
type: {
type: 'keyword',
ignore_above: 256,
},
},
},
slo: {
properties: {
id: {
type: 'keyword',
ignore_above: 256,
},
revision: {
type: 'long',
},
instanceId: {
type: 'keyword',
ignore_above: 256,
},
name: {
type: 'keyword',
ignore_above: 256,
},
description: {
type: 'keyword',
ignore_above: 256,
},
tags: {
type: 'keyword',
ignore_above: 256,
},
indicator: {
properties: {
type: {
type: 'keyword',
ignore_above: 256,
},
},
},
budgetingMethod: {
type: 'keyword',
},
timeWindow: {
properties: {
duration: {
type: 'keyword',
},
type: {
type: 'keyword',
},
},
},
},
},
sliValue: {
type: 'double',
},
goodEvents: {
type: 'long',
},
totalEvents: {
type: 'long',
},
errorBudgetInitial: {
type: 'double',
},
errorBudgetConsumed: {
type: 'double',
},
errorBudgetRemaining: {
type: 'double',
},
errorBudgetEstimated: {
type: 'boolean',
},
statusCode: {
type: 'byte',
},
status: {
type: 'keyword',
ignore_above: 32,
},
isTempDoc: {
type: 'boolean',
},
},
},
},
_meta: {
description: 'SLO summary mappings template',
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},
});

View file

@ -0,0 +1,23 @@
/*
* 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 { SLO_RESOURCES_VERSION } from '../constants';
export const getSLOSummarySettingsTemplate = (name: string) => ({
name,
template: {
settings: {
hidden: true,
},
},
_meta: {
description: 'SLO summary settings template',
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},
});

View file

@ -5,13 +5,32 @@
* 2.0.
*/
export const SLO_RESOURCES_VERSION = 2;
export const SLO_COMPONENT_TEMPLATE_MAPPINGS_NAME = '.slo-observability.sli-mappings';
export const SLO_COMPONENT_TEMPLATE_SETTINGS_NAME = '.slo-observability.sli-settings';
export const SLO_INDEX_TEMPLATE_NAME = '.slo-observability.sli';
export const SLO_RESOURCES_VERSION = 1;
export const SLO_INGEST_PIPELINE_NAME = `${SLO_INDEX_TEMPLATE_NAME}.monthly`;
export const SLO_DESTINATION_INDEX_NAME = `${SLO_INDEX_TEMPLATE_NAME}-v${SLO_RESOURCES_VERSION}`;
export const SLO_DESTINATION_INDEX_PATTERN = `${SLO_DESTINATION_INDEX_NAME}*`;
export const SLO_INDEX_TEMPLATE_PATTERN = `.slo-observability.sli-*`;
export const SLO_DESTINATION_INDEX_NAME = `.slo-observability.sli-v${SLO_RESOURCES_VERSION}`;
export const SLO_DESTINATION_INDEX_PATTERN = `.slo-observability.sli-v${SLO_RESOURCES_VERSION}*`;
export const SLO_INGEST_PIPELINE_NAME = `.slo-observability.sli.pipeline`;
// slo-observability.sli-v<version>.(YYYY-MM-DD)
export const SLO_INGEST_PIPELINE_INDEX_NAME_PREFIX = `.slo-observability.sli-v${SLO_RESOURCES_VERSION}.`;
export const SLO_SUMMARY_COMPONENT_TEMPLATE_MAPPINGS_NAME = '.slo-observability.summary-mappings';
export const SLO_SUMMARY_COMPONENT_TEMPLATE_SETTINGS_NAME = '.slo-observability.summary-settings';
export const SLO_SUMMARY_INDEX_TEMPLATE_NAME = '.slo-observability.summary';
export const SLO_SUMMARY_INDEX_TEMPLATE_PATTERN = `.slo-observability.summary-*`;
export const SLO_SUMMARY_TRANSFORM_NAME_PREFIX = 'slo-summary-';
export const SLO_SUMMARY_DESTINATION_INDEX_NAME = `.slo-observability.summary-v${SLO_RESOURCES_VERSION}`; // store the temporary summary document generated by transform
export const SLO_SUMMARY_TEMP_INDEX_NAME = `.slo-observability.summary-v${SLO_RESOURCES_VERSION}.temp`; // store the temporary summary document
export const SLO_SUMMARY_DESTINATION_INDEX_PATTERN = `.slo-observability.summary-v${SLO_RESOURCES_VERSION}*`; // include temp and non-temp summary indices
export const SLO_SUMMARY_INGEST_PIPELINE_NAME = `.slo-observability.summary.pipeline`;
export const getSLOTransformId = (sloId: string, sloRevision: number) =>
`slo-${sloId}-${sloRevision}`;

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { SLO_RESOURCES_VERSION } from '../constants';
export const getSLOIndexTemplate = (name: string, indexPattern: string, composedOf: string[]) => ({
name,
index_patterns: [indexPattern],
@ -12,7 +14,7 @@ export const getSLOIndexTemplate = (name: string, indexPattern: string, composed
priority: 500,
_meta: {
description: 'Template for SLO rollup data',
version: 1,
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},

View file

@ -0,0 +1,25 @@
/*
* 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 { SLO_RESOURCES_VERSION } from '../constants';
export const getSLOSummaryIndexTemplate = (
name: string,
indexPattern: string,
composedOf: string[]
) => ({
name,
index_patterns: [indexPattern],
composed_of: composedOf,
priority: 500,
_meta: {
description: 'SLO summary index template',
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},
});

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { SLO_RESOURCES_VERSION } from '../constants';
export const getSLOPipelineTemplate = (id: string, indexNamePrefix: string) => ({
id,
description: 'Monthly date-time index naming for SLO data',
@ -19,7 +21,7 @@ export const getSLOPipelineTemplate = (id: string, indexNamePrefix: string) => (
],
_meta: {
description: 'SLO ingest pipeline',
version: 1,
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},

View file

@ -0,0 +1,60 @@
/*
* 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 { SLO_RESOURCES_VERSION } from '../constants';
export const getSLOSummaryPipelineTemplate = (id: string) => ({
id,
description: 'SLO summary ingest pipeline',
processors: [
{
split: {
description: 'Split comma separated list of tags into an array',
field: 'slo.tags',
separator: ',',
},
},
{
set: {
description: "if 'statusCode == 0', set status to NO_DATA",
if: 'ctx.statusCode == 0',
field: 'status',
value: 'NO_DATA',
},
},
{
set: {
description: "if 'statusCode == 1', set statusLabel to VIOLATED",
if: 'ctx.statusCode == 1',
field: 'status',
value: 'VIOLATED',
},
},
{
set: {
description: "if 'statusCode == 2', set status to DEGRADING",
if: 'ctx.statusCode == 2',
field: 'status',
value: 'DEGRADING',
},
},
{
set: {
description: "if 'statusCode == 4', set status to HEALTHY",
if: 'ctx.statusCode == 4',
field: 'status',
value: 'HEALTHY',
},
},
],
_meta: {
description: 'SLO summary ingest pipeline',
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},
});

View file

@ -12,6 +12,7 @@ import {
TransformSource,
TransformTimeSync,
} from '@elastic/elasticsearch/lib/api/types';
import { SLO_RESOURCES_VERSION } from '../constants';
export interface TransformSettings {
frequency: TransformPutTransformRequest['frequency'];
@ -47,7 +48,7 @@ export const getSLOTransformTemplate = (
aggregations,
},
_meta: {
version: 1,
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},

View file

@ -5,10 +5,42 @@
* 2.0.
*/
import {
calendarAlignedTimeWindowSchema,
rollingTimeWindowSchema,
timeWindowSchema,
} from '@kbn/slo-schema';
import moment from 'moment';
import * as t from 'io-ts';
import { rollingTimeWindowSchema, timeWindowSchema } from '@kbn/slo-schema';
type TimeWindow = t.TypeOf<typeof timeWindowSchema>;
type RollingTimeWindow = t.TypeOf<typeof rollingTimeWindowSchema>;
type CalendarAlignedTimeWindow = t.TypeOf<typeof calendarAlignedTimeWindowSchema>;
export type { RollingTimeWindow, TimeWindow };
export type { RollingTimeWindow, TimeWindow, CalendarAlignedTimeWindow };
export function toCalendarAlignedTimeWindowMomentUnit(
timeWindow: CalendarAlignedTimeWindow
): moment.unitOfTime.StartOf {
const unit = timeWindow.duration.unit;
switch (unit) {
case 'w':
return 'isoWeeks';
case 'M':
return 'months';
default:
throw new Error(`Invalid calendar aligned time window duration unit: ${unit}`);
}
}
export function toRollingTimeWindowMomentUnit(
timeWindow: RollingTimeWindow
): moment.unitOfTime.Diff {
const unit = timeWindow.duration.unit;
switch (unit) {
case 'd':
return 'days';
default:
throw new Error(`Invalid rolling time window duration unit: ${unit}`);
}
}

View file

@ -8,7 +8,7 @@
import { computeBurnRate } from './compute_burn_rate';
import { toDateRange } from './date_range';
import { createSLO } from '../../services/slo/fixtures/slo';
import { sixHoursRolling } from '../../services/slo/fixtures/time_window';
import { ninetyDaysRolling } from '../../services/slo/fixtures/time_window';
describe('computeBurnRate', () => {
it('computes 0 when total is 0', () => {
@ -16,7 +16,7 @@ describe('computeBurnRate', () => {
computeBurnRate(createSLO(), {
good: 10,
total: 0,
dateRange: toDateRange(sixHoursRolling()),
dateRange: toDateRange(ninetyDaysRolling()),
})
).toEqual(0);
});
@ -26,7 +26,7 @@ describe('computeBurnRate', () => {
computeBurnRate(createSLO(), {
good: 9999,
total: 1,
dateRange: toDateRange(sixHoursRolling()),
dateRange: toDateRange(ninetyDaysRolling()),
})
).toEqual(0);
});
@ -36,7 +36,7 @@ describe('computeBurnRate', () => {
computeBurnRate(createSLO({ objective: { target: 0.9 } }), {
good: 90,
total: 100,
dateRange: toDateRange(sixHoursRolling()),
dateRange: toDateRange(ninetyDaysRolling()),
})
).toEqual(1);
});
@ -46,7 +46,7 @@ describe('computeBurnRate', () => {
computeBurnRate(createSLO({ objective: { target: 0.99 } }), {
good: 90,
total: 100,
dateRange: toDateRange(sixHoursRolling()),
dateRange: toDateRange(ninetyDaysRolling()),
})
).toEqual(10);
});
@ -56,7 +56,7 @@ describe('computeBurnRate', () => {
computeBurnRate(createSLO({ objective: { target: 0.8 } }), {
good: 90,
total: 100,
dateRange: toDateRange(sixHoursRolling()),
dateRange: toDateRange(ninetyDaysRolling()),
})
).toEqual(0.5);
});

View file

@ -5,25 +5,29 @@
* 2.0.
*/
import { TimeWindow } from '../models/time_window';
import { Duration } from '../models';
import {
monthlyCalendarAligned,
ninetyDaysRolling,
sevenDaysRolling,
thirtyDaysRolling,
weeklyCalendarAligned,
} from '../../services/slo/fixtures/time_window';
import { toDateRange } from './date_range';
import { oneMonth, oneQuarter, oneWeek, thirtyDays } from '../../services/slo/fixtures/duration';
const NOW = new Date('2022-08-11T08:31:00.000Z');
describe('toDateRange', () => {
describe('for calendar aligned time window', () => {
it('computes the date range for weekly calendar', () => {
const timeWindow = aCalendarTimeWindow(oneWeek());
const timeWindow = weeklyCalendarAligned();
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-08-07T00:00:00.000Z'),
to: new Date('2022-08-13T23:59:59.999Z'),
from: new Date('2022-08-08T00:00:00.000Z'),
to: new Date('2022-08-14T23:59:59.999Z'),
});
});
it('computes the date range for monthly calendar', () => {
const timeWindow = aCalendarTimeWindow(oneMonth());
const timeWindow = monthlyCalendarAligned();
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-08-01T00:00:00.000Z'),
to: new Date('2022-08-31T23:59:59.999Z'),
@ -33,42 +37,24 @@ describe('toDateRange', () => {
describe('for rolling time window', () => {
it("computes the date range using a '30days' rolling window", () => {
expect(toDateRange(aRollingTimeWindow(thirtyDays()), NOW)).toEqual({
expect(toDateRange(thirtyDaysRolling(), NOW)).toEqual({
from: new Date('2022-07-12T08:31:00.000Z'),
to: new Date('2022-08-11T08:31:00.000Z'),
});
});
it("computes the date range using a 'weekly' rolling window", () => {
expect(toDateRange(aRollingTimeWindow(oneWeek()), NOW)).toEqual({
it("computes the date range using a '7days' rolling window", () => {
expect(toDateRange(sevenDaysRolling(), NOW)).toEqual({
from: new Date('2022-08-04T08:31:00.000Z'),
to: new Date('2022-08-11T08:31:00.000Z'),
});
});
it("computes the date range using a 'monthly' rolling window", () => {
expect(toDateRange(aRollingTimeWindow(oneMonth()), NOW)).toEqual({
from: new Date('2022-07-11T08:31:00.000Z'),
to: new Date('2022-08-11T08:31:00.000Z'),
});
});
it("computes the date range using a 'quarterly' rolling window", () => {
expect(toDateRange(aRollingTimeWindow(oneQuarter()), NOW)).toEqual({
from: new Date('2022-05-11T08:31:00.000Z'),
it("computes the date range using a '90days' rolling window", () => {
expect(toDateRange(ninetyDaysRolling(), NOW)).toEqual({
from: new Date('2022-05-13T08:31:00.000Z'),
to: new Date('2022-08-11T08:31:00.000Z'),
});
});
});
});
function aCalendarTimeWindow(duration: Duration): TimeWindow {
return {
duration,
type: 'calendarAligned',
};
}
function aRollingTimeWindow(duration: Duration): TimeWindow {
return { duration, type: 'rolling' };
}

View file

@ -5,16 +5,19 @@
* 2.0.
*/
import { calendarAlignedTimeWindowSchema, rollingTimeWindowSchema } from '@kbn/slo-schema';
import { assertNever } from '@kbn/std';
import moment from 'moment';
import { calendarAlignedTimeWindowSchema, rollingTimeWindowSchema } from '@kbn/slo-schema';
import { DateRange, toMomentUnitOfTime } from '../models';
import type { TimeWindow } from '../models/time_window';
import { DateRange } from '../models';
import {
TimeWindow,
toCalendarAlignedTimeWindowMomentUnit,
toRollingTimeWindowMomentUnit,
} from '../models/time_window';
export const toDateRange = (timeWindow: TimeWindow, currentDate: Date = new Date()): DateRange => {
if (calendarAlignedTimeWindowSchema.is(timeWindow)) {
const unit = toMomentUnitOfTime(timeWindow.duration.unit);
const unit = toCalendarAlignedTimeWindowMomentUnit(timeWindow);
const from = moment.utc(currentDate).startOf(unit);
const to = moment.utc(currentDate).endOf(unit);
@ -22,12 +25,14 @@ export const toDateRange = (timeWindow: TimeWindow, currentDate: Date = new Date
}
if (rollingTimeWindowSchema.is(timeWindow)) {
const unit = toMomentUnitOfTime(timeWindow.duration.unit);
const unit = toRollingTimeWindowMomentUnit(timeWindow);
const now = moment.utc(currentDate).startOf('minute');
const from = now.clone().subtract(timeWindow.duration.value, unit);
const to = now.clone();
return {
from: now.clone().subtract(timeWindow.duration.value, unit).toDate(),
to: now.toDate(),
from: from.toDate(),
to: to.toDate(),
};
}

View file

@ -8,6 +8,7 @@
import { validateSLO } from '.';
import { oneMinute, sixHours } from '../../services/slo/fixtures/duration';
import { createSLO } from '../../services/slo/fixtures/slo';
import { sevenDaysRolling } from '../../services/slo/fixtures/time_window';
import { Duration, DurationUnit } from '../models';
describe('validateSLO', () => {
@ -41,16 +42,12 @@ describe('validateSLO', () => {
{ duration: new Duration(2, DurationUnit.Hour), shouldThrow: true },
{ duration: new Duration(1, DurationUnit.Day), shouldThrow: true },
{ duration: new Duration(7, DurationUnit.Day), shouldThrow: true },
{ duration: new Duration(1, DurationUnit.Week), shouldThrow: false },
{ duration: new Duration(2, DurationUnit.Week), shouldThrow: true },
{ duration: new Duration(1, DurationUnit.Month), shouldThrow: false },
{ duration: new Duration(2, DurationUnit.Month), shouldThrow: true },
{ duration: new Duration(1, DurationUnit.Quarter), shouldThrow: true },
{ duration: new Duration(3, DurationUnit.Quarter), shouldThrow: true },
{ duration: new Duration(1, DurationUnit.Year), shouldThrow: true },
{ duration: new Duration(3, DurationUnit.Year), shouldThrow: true },
{ duration: new Duration(1, DurationUnit.Week), shouldThrow: false },
{ duration: new Duration(1, DurationUnit.Month), shouldThrow: false },
])(
'throws when time window calendar aligned is not 1 week or 1 month',
'throws when calendar aligned time window is not 1 week or 1 month',
({ duration, shouldThrow }) => {
if (shouldThrow) {
expect(() =>
@ -72,6 +69,34 @@ describe('validateSLO', () => {
}
);
it.each([
{ duration: new Duration(7, DurationUnit.Day), shouldThrow: false },
{ duration: new Duration(30, DurationUnit.Day), shouldThrow: false },
{ duration: new Duration(90, DurationUnit.Day), shouldThrow: false },
{ duration: new Duration(1, DurationUnit.Hour), shouldThrow: true },
{ duration: new Duration(1, DurationUnit.Day), shouldThrow: true },
{ duration: new Duration(1, DurationUnit.Week), shouldThrow: true },
{ duration: new Duration(1, DurationUnit.Month), shouldThrow: true },
])('throws when rolling time window is not 7, 30 or 90days', ({ duration, shouldThrow }) => {
if (shouldThrow) {
expect(() =>
validateSLO(
createSLO({
timeWindow: { duration, type: 'rolling' },
})
)
).toThrowError('Invalid time_window.duration');
} else {
expect(() =>
validateSLO(
createSLO({
timeWindow: { duration, type: 'rolling' },
})
)
).not.toThrowError();
}
});
describe('settings', () => {
it("throws when frequency is longer or equal than '1h'", () => {
const slo = createSLO({
@ -173,25 +198,11 @@ describe('validateSLO', () => {
objective: { ...slo.objective, timesliceWindow: new Duration(1, DurationUnit.Month) },
})
).toThrowError('Invalid objective.timeslice_window');
expect(() =>
validateSLO({
...slo,
objective: { ...slo.objective, timesliceWindow: new Duration(1, DurationUnit.Quarter) },
})
).toThrowError('Invalid objective.timeslice_window');
expect(() =>
validateSLO({
...slo,
objective: { ...slo.objective, timesliceWindow: new Duration(1, DurationUnit.Year) },
})
).toThrowError('Invalid objective.timeslice_window');
});
it("throws when 'objective.timeslice_window' is longer than 'slo.time_window'", () => {
const slo = createSLO({
timeWindow: { duration: new Duration(1, DurationUnit.Week), type: 'rolling' },
timeWindow: sevenDaysRolling(),
budgetingMethod: 'timeslices',
objective: {
target: 0.95,

View file

@ -85,13 +85,8 @@ function isValidTargetNumber(value: number): boolean {
}
function isValidRollingTimeWindowDuration(duration: Duration): boolean {
return [
DurationUnit.Day,
DurationUnit.Week,
DurationUnit.Month,
DurationUnit.Quarter,
DurationUnit.Year,
].includes(duration.unit);
// 7, 30 or 90days accepted
return duration.unit === DurationUnit.Day && [7, 30, 90].includes(duration.value);
}
function isValidCalendarAlignedTimeWindowDuration(duration: Duration): boolean {

View file

@ -5,47 +5,53 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import {
PluginInitializerContext,
Plugin,
CoreSetup,
DEFAULT_APP_CATEGORIES,
Logger,
} from '@kbn/core/server';
import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/saved_objects';
import { PluginSetupContract, PluginStartContract } from '@kbn/alerting-plugin/server';
import { RuleRegistryPluginSetupContract } from '@kbn/rule-registry-plugin/server';
import { PluginSetupContract as FeaturesSetup } from '@kbn/features-plugin/server';
import {
createUICapabilities as createCasesUICapabilities,
getApiTags as getCasesApiTags,
} from '@kbn/cases-plugin/common';
import { SharePluginSetup } from '@kbn/share-plugin/server';
import { SpacesPluginSetup } from '@kbn/spaces-plugin/server';
import type { GuidedOnboardingPluginSetup } from '@kbn/guided-onboarding-plugin/server';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import { CloudSetup } from '@kbn/cloud-plugin/server';
import {
kubernetesGuideId,
kubernetesGuideConfig,
} from '../common/guided_onboarding/kubernetes_guide_config';
CoreSetup,
DEFAULT_APP_CATEGORIES,
Logger,
Plugin,
PluginInitializerContext,
} from '@kbn/core/server';
import { PluginSetupContract as FeaturesSetup } from '@kbn/features-plugin/server';
import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/saved_objects';
import type { GuidedOnboardingPluginSetup } from '@kbn/guided-onboarding-plugin/server';
import { i18n } from '@kbn/i18n';
import { RuleRegistryPluginSetupContract } from '@kbn/rule-registry-plugin/server';
import { SharePluginSetup } from '@kbn/share-plugin/server';
import { SpacesPluginSetup } from '@kbn/spaces-plugin/server';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import { ObservabilityConfig } from '.';
import { casesFeatureId, observabilityFeatureId, sloFeatureId } from '../common';
import { SLO_BURN_RATE_RULE_TYPE_ID } from '../common/constants';
import {
kubernetesGuideConfig,
kubernetesGuideId,
} from '../common/guided_onboarding/kubernetes_guide_config';
import { AlertsLocatorDefinition } from '../common/locators/alerts';
import {
AnnotationsAPI,
bootstrapAnnotations,
ScopedAnnotationsClientFactory,
AnnotationsAPI,
} from './lib/annotations/bootstrap_annotations';
import { uiSettings } from './ui_settings';
import { registerRoutes } from './routes/register_routes';
import { getObservabilityServerRouteRepository } from './routes/get_global_observability_server_route_repository';
import { compositeSlo, slo, SO_COMPOSITE_SLO_TYPE, SO_SLO_TYPE } from './saved_objects';
import { AlertsLocatorDefinition } from '../common/locators/alerts';
import { casesFeatureId, observabilityFeatureId, sloFeatureId } from '../common';
import { registerRuleTypes } from './lib/rules/register_rule_types';
import { SLO_BURN_RATE_RULE_TYPE_ID } from '../common/constants';
import { registerSloUsageCollector } from './lib/collectors/register';
import { registerRuleTypes } from './lib/rules/register_rule_types';
import { getObservabilityServerRouteRepository } from './routes/get_global_observability_server_route_repository';
import { registerRoutes } from './routes/register_routes';
import { compositeSlo, slo, SO_COMPOSITE_SLO_TYPE, SO_SLO_TYPE } from './saved_objects';
import { threshold } from './saved_objects/threshold';
import {
DefaultResourceInstaller,
DefaultSLOInstaller,
DefaultSummaryTransformInstaller,
} from './services/slo';
import { uiSettings } from './ui_settings';
export type ObservabilityPluginSetup = ReturnType<ObservabilityPlugin['setup']>;
@ -258,6 +264,20 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
logger: this.logger,
repository: getObservabilityServerRouteRepository(config),
});
const esInternalClient = coreStart.elasticsearch.client.asInternalUser;
const sloResourceInstaller = new DefaultResourceInstaller(esInternalClient, this.logger);
const sloSummaryInstaller = new DefaultSummaryTransformInstaller(
esInternalClient,
this.logger
);
const sloInstaller = new DefaultSLOInstaller(
sloResourceInstaller,
sloSummaryInstaller,
this.logger
);
sloInstaller.install();
});
/**

View file

@ -5,22 +5,22 @@
* 2.0.
*/
import { forbidden, failedDependency } from '@hapi/boom';
import { failedDependency, forbidden } from '@hapi/boom';
import {
createSLOParamsSchema,
deleteSLOParamsSchema,
fetchHistoricalSummaryParamsSchema,
findSLOParamsSchema,
getSLOBurnRatesParamsSchema,
getPreviewDataParamsSchema,
getSLOBurnRatesParamsSchema,
getSLODiagnosisParamsSchema,
getSLOParamsSchema,
manageSLOParamsSchema,
updateSLOParamsSchema,
} from '@kbn/slo-schema';
import type { IndicatorTypes } from '../../domain/models';
import {
CreateSLO,
DefaultResourceInstaller,
DefaultSummaryClient,
DefaultTransformManager,
DeleteSLO,
@ -29,6 +29,13 @@ import {
KibanaSavedObjectsSLORepository,
UpdateSLO,
} from '../../services/slo';
import { FetchHistoricalSummary } from '../../services/slo/fetch_historical_summary';
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 { DefaultHistoricalSummaryClient } from '../../services/slo/historical_summary_client';
import { ManageSLO } from '../../services/slo/manage_slo';
import { DefaultSummarySearchClient } from '../../services/slo/summary_search_client';
import {
ApmTransactionDurationTransformGenerator,
ApmTransactionErrorRateTransformGenerator,
@ -37,15 +44,8 @@ import {
MetricCustomTransformGenerator,
TransformGenerator,
} from '../../services/slo/transform_generators';
import { createObservabilityServerRoute } from '../create_observability_server_route';
import { DefaultHistoricalSummaryClient } from '../../services/slo/historical_summary_client';
import { FetchHistoricalSummary } from '../../services/slo/fetch_historical_summary';
import type { IndicatorTypes } from '../../domain/models';
import type { ObservabilityRequestHandlerContext } from '../../types';
import { ManageSLO } from '../../services/slo/manage_slo';
import { getGlobalDiagnosis, getSloDiagnosis } from '../../services/slo/get_diagnosis';
import { getBurnRates } from '../../services/slo/get_burn_rates';
import { GetPreviewData } from '../../services/slo/get_preview_data';
import { createObservabilityServerRoute } from '../create_observability_server_route';
const transformGenerators: Record<IndicatorTypes, TransformGenerator> = {
'sli.apm.transactionDuration': new ApmTransactionDurationTransformGenerator(),
@ -75,11 +75,9 @@ const createSLORoute = createObservabilityServerRoute({
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const soClient = (await context.core).savedObjects.client;
const resourceInstaller = new DefaultResourceInstaller(esClient, logger);
const repository = new KibanaSavedObjectsSLORepository(soClient);
const transformManager = new DefaultTransformManager(transformGenerators, esClient, logger);
const createSLO = new CreateSLO(resourceInstaller, repository, transformManager);
const createSLO = new CreateSLO(esClient, repository, transformManager);
const response = await createSLO.execute(params.body);
@ -228,7 +226,7 @@ const findSLORoute = createObservabilityServerRoute({
tags: ['access:slo_read'],
},
params: findSLOParamsSchema,
handler: async ({ context, params }) => {
handler: async ({ context, params, logger }) => {
const hasCorrectLicense = await isLicenseAtLeastPlatinum(context);
if (!hasCorrectLicense) {
@ -238,8 +236,8 @@ const findSLORoute = createObservabilityServerRoute({
const soClient = (await context.core).savedObjects.client;
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const repository = new KibanaSavedObjectsSLORepository(soClient);
const summaryClient = new DefaultSummaryClient(esClient);
const findSLO = new FindSLO(repository, summaryClient);
const summarySearchClient = new DefaultSummarySearchClient(esClient, logger);
const findSLO = new FindSLO(repository, summarySearchClient);
const response = await findSLO.execute(params?.query ?? {});
@ -304,8 +302,9 @@ const getSloDiagnosisRoute = createObservabilityServerRoute({
handler: async ({ context, params }) => {
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const soClient = (await context.core).savedObjects.client;
const repository = new KibanaSavedObjectsSLORepository(soClient);
return getSloDiagnosis(params.path.id, { esClient, soClient });
return getSloDiagnosis(params.path.id, { esClient, repository });
},
});

View file

@ -16,7 +16,7 @@ Object {
exports[`SummaryClient fetchSummary with a rolling and occurrences composite SLO returns the summary 2`] = `
Array [
Object {
"index": ".slo-observability.sli-v1*",
"index": ".slo-observability.sli-v2*",
},
Object {
"aggs": Object {
@ -108,7 +108,7 @@ Object {
exports[`SummaryClient with rolling and timeslices SLO returns the summary 2`] = `
Array [
Object {
"index": ".slo-observability.sli-v1*",
"index": ".slo-observability.sli-v2*",
},
Object {
"aggs": Object {

View file

@ -0,0 +1,46 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CreateSLO happy path calls the expected services 1`] = `
Array [
Object {
"document": Object {
"errorBudgetConsumed": 0,
"errorBudgetEstimated": false,
"errorBudgetInitial": 0.010000000000000009,
"errorBudgetRemaining": 1,
"goodEvents": 0,
"isTempDoc": true,
"service": Object {
"environment": null,
"name": null,
},
"sliValue": -1,
"slo": Object {
"budgetingMethod": "occurrences",
"description": "irrelevant",
"id": "unique-id",
"indicator": Object {
"type": "sli.apm.transactionErrorRate",
},
"instanceId": "*",
"name": "irrelevant",
"revision": 1,
"tags": Array [],
"timeWindow": Object {
"duration": "7d",
"type": "rolling",
},
},
"status": "NO_DATA",
"statusCode": 0,
"totalEvents": 0,
"transaction": Object {
"name": null,
"type": null,
},
},
"id": "slo-unique-id",
"index": ".slo-observability.summary-v2.temp",
},
]
`;

View file

@ -0,0 +1,106 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Summary Search Client returns the summary documents without duplicate temporary summary documents 1`] = `
Array [
Object {
"index": ".slo-observability.summary-v2*",
"query": Object {
"bool": Object {
"filter": Array [
Object {
"terms": Object {
"slo.id": Array [
"slo-one",
"slo_two",
"slo-three",
"slo-five",
],
},
},
Object {
"term": Object {
"isTempDoc": true,
},
},
],
},
},
"wait_for_completion": false,
},
]
`;
exports[`Summary Search Client returns the summary documents without duplicate temporary summary documents 2`] = `
Object {
"page": 1,
"perPage": 20,
"results": Array [
Object {
"id": "slo-one",
"summary": Object {
"errorBudget": Object {
"consumed": 0.4,
"initial": 0.02,
"isEstimated": false,
"remaining": 0.6,
},
"sliValue": 0.9,
"status": "HEALTHY",
},
},
Object {
"id": "slo_two",
"summary": Object {
"errorBudget": Object {
"consumed": 0.4,
"initial": 0.02,
"isEstimated": false,
"remaining": 0.6,
},
"sliValue": 0.9,
"status": "HEALTHY",
},
},
Object {
"id": "slo-three",
"summary": Object {
"errorBudget": Object {
"consumed": 0.4,
"initial": 0.02,
"isEstimated": false,
"remaining": 0.6,
},
"sliValue": 0.9,
"status": "HEALTHY",
},
},
Object {
"id": "slo-five",
"summary": Object {
"errorBudget": Object {
"consumed": 0.4,
"initial": 0.02,
"isEstimated": false,
"remaining": 0.6,
},
"sliValue": 0.9,
"status": "HEALTHY",
},
},
Object {
"id": "slo-four",
"summary": Object {
"errorBudget": Object {
"consumed": 0.4,
"initial": 0.02,
"isEstimated": false,
"remaining": 0.6,
},
"sliValue": 0.9,
"status": "HEALTHY",
},
},
],
"total": 5,
}
`;

View file

@ -0,0 +1,49 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UpdateSLO index a temporary summary document 1`] = `
Array [
Object {
"document": Object {
"errorBudgetConsumed": 0,
"errorBudgetEstimated": false,
"errorBudgetInitial": 0.0010000000000000009,
"errorBudgetRemaining": 1,
"goodEvents": 0,
"isTempDoc": true,
"service": Object {
"environment": null,
"name": null,
},
"sliValue": -1,
"slo": Object {
"budgetingMethod": "occurrences",
"description": "irrelevant",
"id": "unique-id",
"indicator": Object {
"type": "sli.apm.transactionErrorRate",
},
"instanceId": "*",
"name": "irrelevant",
"revision": 2,
"tags": Array [
"critical",
"k8s",
],
"timeWindow": Object {
"duration": "7d",
"type": "rolling",
},
},
"status": "NO_DATA",
"statusCode": 0,
"totalEvents": 0,
"transaction": Object {
"name": null,
"type": null,
},
},
"id": "slo-unique-id",
"index": ".slo-observability.summary-v2.temp",
},
]
`;

View file

@ -5,43 +5,41 @@
* 2.0.
*/
import { ElasticsearchClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { CreateSLO } from './create_slo';
import { fiveMinute, oneMinute } from './fixtures/duration';
import { createAPMTransactionErrorRateIndicator, createSLOParams } from './fixtures/slo';
import {
createResourceInstallerMock,
createSLORepositoryMock,
createTransformManagerMock,
} from './mocks';
import { ResourceInstaller } from './resource_installer';
import { createSLORepositoryMock, createTransformManagerMock } from './mocks';
import { SLORepository } from './slo_repository';
import { TransformManager } from './transform_manager';
describe('CreateSLO', () => {
let mockResourceInstaller: jest.Mocked<ResourceInstaller>;
let esClientMock: ElasticsearchClientMock;
let mockRepository: jest.Mocked<SLORepository>;
let mockTransformManager: jest.Mocked<TransformManager>;
let createSLO: CreateSLO;
beforeEach(() => {
mockResourceInstaller = createResourceInstallerMock();
esClientMock = elasticsearchServiceMock.createElasticsearchClient();
mockRepository = createSLORepositoryMock();
mockTransformManager = createTransformManagerMock();
createSLO = new CreateSLO(mockResourceInstaller, mockRepository, mockTransformManager);
createSLO = new CreateSLO(esClientMock, mockRepository, mockTransformManager);
});
describe('happy path', () => {
it('calls the expected services', async () => {
const sloParams = createSLOParams({ indicator: createAPMTransactionErrorRateIndicator() });
const sloParams = createSLOParams({
id: 'unique-id',
indicator: createAPMTransactionErrorRateIndicator(),
});
mockTransformManager.install.mockResolvedValue('slo-transform-id');
const response = await createSLO.execute(sloParams);
expect(mockResourceInstaller.ensureCommonResourcesInstalled).toHaveBeenCalled();
expect(mockRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
...sloParams,
id: expect.any(String),
id: 'unique-id',
settings: {
syncDelay: oneMinute(),
frequency: oneMinute(),
@ -55,10 +53,11 @@ describe('CreateSLO', () => {
{ throwOnConflict: true }
);
expect(mockTransformManager.install).toHaveBeenCalledWith(
expect.objectContaining({ ...sloParams, id: expect.any(String) })
expect.objectContaining({ ...sloParams, id: 'unique-id' })
);
expect(mockTransformManager.start).toHaveBeenCalledWith('slo-transform-id');
expect(response).toEqual(expect.objectContaining({ id: expect.any(String) }));
expect(response).toEqual(expect.objectContaining({ id: 'unique-id' }));
expect(esClientMock.index.mock.calls[0]).toMatchSnapshot();
});
it('overrides the default values when provided', async () => {
@ -73,7 +72,6 @@ describe('CreateSLO', () => {
await createSLO.execute(sloParams);
expect(mockResourceInstaller.ensureCommonResourcesInstalled).toHaveBeenCalled();
expect(mockRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
...sloParams,

View file

@ -5,19 +5,19 @@
* 2.0.
*/
import { v1 as uuidv1 } from 'uuid';
import { ElasticsearchClient } from '@kbn/core/server';
import { 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';
import { ResourceInstaller } from './resource_installer';
import { SLORepository } from './slo_repository';
import { TransformManager } from './transform_manager';
import { validateSLO } from '../../domain/services';
import { SLORepository } from './slo_repository';
import { createTempSummaryDocument } from './summary_transform/helpers/create_temp_summary';
import { TransformManager } from './transform_manager';
export class CreateSLO {
constructor(
private resourceInstaller: ResourceInstaller,
private esClient: ElasticsearchClient,
private repository: SLORepository,
private transformManager: TransformManager
) {}
@ -26,9 +26,7 @@ export class CreateSLO {
const slo = this.toSLO(params);
validateSLO(slo);
await this.resourceInstaller.ensureCommonResourcesInstalled();
await this.repository.save(slo, { throwOnConflict: true });
let sloTransformId;
try {
sloTransformId = await this.transformManager.install(slo);
@ -48,6 +46,12 @@ export class CreateSLO {
throw err;
}
await this.esClient.index({
index: SLO_SUMMARY_TEMP_INDEX_NAME,
id: `slo-${slo.id}`,
document: createTempSummaryDocument(slo),
});
return this.toResponse(slo);
}

View file

@ -5,11 +5,15 @@
* 2.0.
*/
import { RulesClientApi } from '@kbn/alerting-plugin/server/types';
import { rulesClientMock } from '@kbn/alerting-plugin/server/rules_client.mock';
import { RulesClientApi } from '@kbn/alerting-plugin/server/types';
import { ElasticsearchClient } from '@kbn/core/server';
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { getSLOTransformId, SLO_INDEX_TEMPLATE_NAME } from '../../assets/constants';
import {
getSLOTransformId,
SLO_DESTINATION_INDEX_PATTERN,
SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
} from '../../assets/constants';
import { DeleteSLO } from './delete_slo';
import { createAPMTransactionErrorRateIndicator, createSLO } from './fixtures/slo';
import { createSLORepositoryMock, createTransformManagerMock } from './mocks';
@ -45,9 +49,22 @@ describe('DeleteSLO', () => {
expect(mockTransformManager.uninstall).toHaveBeenCalledWith(
getSLOTransformId(slo.id, slo.revision)
);
expect(mockEsClient.deleteByQuery).toHaveBeenCalledWith(
expect(mockEsClient.deleteByQuery).toHaveBeenCalledTimes(2);
expect(mockEsClient.deleteByQuery).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
index: `${SLO_INDEX_TEMPLATE_NAME}*`,
index: SLO_DESTINATION_INDEX_PATTERN,
query: {
match: {
'slo.id': slo.id,
},
},
})
);
expect(mockEsClient.deleteByQuery).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
index: SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
query: {
match: {
'slo.id': slo.id,

View file

@ -7,8 +7,11 @@
import { RulesClientApi } from '@kbn/alerting-plugin/server/types';
import { ElasticsearchClient } from '@kbn/core/server';
import { getSLOTransformId, SLO_INDEX_TEMPLATE_NAME } from '../../assets/constants';
import {
getSLOTransformId,
SLO_DESTINATION_INDEX_PATTERN,
SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
} from '../../assets/constants';
import { SLORepository } from './slo_repository';
import { TransformManager } from './transform_manager';
@ -28,13 +31,14 @@ export class DeleteSLO {
await this.transformManager.uninstall(sloTransformId);
await this.deleteRollupData(slo.id);
await this.deleteSummaryData(slo.id);
await this.deleteAssociatedRules(slo.id);
await this.repository.deleteById(slo.id);
}
private async deleteRollupData(sloId: string): Promise<void> {
await this.esClient.deleteByQuery({
index: `${SLO_INDEX_TEMPLATE_NAME}*`,
index: SLO_DESTINATION_INDEX_PATTERN,
wait_for_completion: false,
query: {
match: {
@ -44,6 +48,17 @@ export class DeleteSLO {
});
}
private async deleteSummaryData(sloId: string): Promise<void> {
await this.esClient.deleteByQuery({
index: SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
wait_for_completion: false,
query: {
match: {
'slo.id': sloId,
},
},
});
}
private async deleteAssociatedRules(sloId: string): Promise<void> {
try {
await this.rulesClient.bulkDeleteRules({

View file

@ -5,37 +5,45 @@
* 2.0.
*/
import { SLO, SLOId, Summary } from '../../domain/models';
import { SLO } from '../../domain/models';
import { FindSLO } from './find_slo';
import { createSLO, createPaginatedSLO } from './fixtures/slo';
import { createSummaryClientMock, createSLORepositoryMock } from './mocks';
import { SLORepository, SortField, SortDirection } from './slo_repository';
import { SummaryClient } from './summary_client';
import { createSLO } from './fixtures/slo';
import { createSLORepositoryMock, createSummarySearchClientMock } from './mocks';
import { SLORepository } from './slo_repository';
import { Paginated, SLOSummary, SummarySearchClient } from './summary_search_client';
describe('FindSLO', () => {
let mockRepository: jest.Mocked<SLORepository>;
let mockSummaryClient: jest.Mocked<SummaryClient>;
let mockSummarySearchClient: jest.Mocked<SummarySearchClient>;
let findSLO: FindSLO;
beforeEach(() => {
mockRepository = createSLORepositoryMock();
mockSummaryClient = createSummaryClientMock();
findSLO = new FindSLO(mockRepository, mockSummaryClient);
mockSummarySearchClient = createSummarySearchClientMock();
findSLO = new FindSLO(mockRepository, mockSummarySearchClient);
});
describe('happy path', () => {
it('returns the results with pagination', async () => {
const slo = createSLO();
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
mockSummaryClient.fetchSummary.mockResolvedValueOnce(someSummary(slo));
mockSummarySearchClient.search.mockResolvedValueOnce(summarySearchResult(slo));
mockRepository.findAllByIds.mockResolvedValueOnce([slo]);
const result = await findSLO.execute({});
expect(mockRepository.find).toHaveBeenCalledWith(
{ name: undefined },
{ field: SortField.CreationTime, direction: SortDirection.Asc },
{ page: 1, perPage: 25 }
);
expect(mockSummarySearchClient.search.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"",
Object {
"direction": "asc",
"field": "status",
},
Object {
"page": 1,
"perPage": 25,
},
]
`);
expect(result).toEqual({
page: 1,
@ -89,131 +97,65 @@ describe('FindSLO', () => {
});
});
it('calls the repository with the default criteria and pagination', async () => {
it('calls the repository with all the summary slo ids', async () => {
const slo = createSLO();
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
mockSummaryClient.fetchSummary.mockResolvedValueOnce(someSummary(slo));
mockSummarySearchClient.search.mockResolvedValueOnce(summarySearchResult(slo));
mockRepository.findAllByIds.mockResolvedValueOnce([slo]);
await findSLO.execute({});
expect(mockRepository.find).toHaveBeenCalledWith(
{ name: undefined },
{ field: SortField.CreationTime, direction: SortDirection.Asc },
{ page: 1, perPage: 25 }
);
expect(mockRepository.findAllByIds).toHaveBeenCalledWith([slo.id]);
});
it('calls the repository with the name filter criteria', async () => {
it('searches with the provided criteria', async () => {
const slo = createSLO();
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
mockSummaryClient.fetchSummary.mockResolvedValueOnce(someSummary(slo));
mockSummarySearchClient.search.mockResolvedValueOnce(summarySearchResult(slo));
mockRepository.findAllByIds.mockResolvedValueOnce([slo]);
await findSLO.execute({ name: 'Availability' });
await findSLO.execute({
kqlQuery: "slo.name:'Service*' and slo.indicator.type:'sli.kql.custom'",
page: '2',
perPage: '10',
sortBy: 'error_budget_consumed',
sortDirection: 'asc',
});
expect(mockRepository.find).toHaveBeenCalledWith(
{ name: 'Availability' },
{ field: SortField.CreationTime, direction: SortDirection.Asc },
{ page: 1, perPage: 25 }
);
});
it('calls the repository with the indicatorType filter criteria', async () => {
const slo = createSLO();
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
mockSummaryClient.fetchSummary.mockResolvedValueOnce(someSummary(slo));
await findSLO.execute({ indicatorTypes: ['sli.kql.custom'] });
expect(mockRepository.find).toHaveBeenCalledWith(
{ indicatorTypes: ['sli.kql.custom'] },
{ field: SortField.CreationTime, direction: SortDirection.Asc },
{ page: 1, perPage: 25 }
);
});
it('calls the repository with the pagination', async () => {
const slo = createSLO();
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
mockSummaryClient.fetchSummary.mockResolvedValueOnce(someSummary(slo));
await findSLO.execute({ name: 'My SLO*', page: '2', perPage: '100' });
expect(mockRepository.find).toHaveBeenCalledWith(
{ name: 'My SLO*' },
{ field: SortField.CreationTime, direction: SortDirection.Asc },
{ page: 2, perPage: 100 }
);
});
it('uses default pagination values when invalid', async () => {
const slo = createSLO();
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
mockSummaryClient.fetchSummary.mockResolvedValueOnce(someSummary(slo));
await findSLO.execute({ page: '-1', perPage: '0' });
expect(mockRepository.find).toHaveBeenCalledWith(
{ name: undefined },
{ field: SortField.CreationTime, direction: SortDirection.Asc },
{ page: 1, perPage: 25 }
);
});
it('sorts by name by default when not specified', async () => {
const slo = createSLO();
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
mockSummaryClient.fetchSummary.mockResolvedValueOnce(someSummary(slo));
await findSLO.execute({ sortBy: undefined });
expect(mockRepository.find).toHaveBeenCalledWith(
{ name: undefined },
{ field: SortField.CreationTime, direction: SortDirection.Asc },
{ page: 1, perPage: 25 }
);
});
it('sorts by indicator type', async () => {
const slo = createSLO();
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
mockSummaryClient.fetchSummary.mockResolvedValueOnce(someSummary(slo));
await findSLO.execute({ sortBy: 'indicatorType' });
expect(mockRepository.find).toHaveBeenCalledWith(
{ name: undefined },
{ field: SortField.IndicatorType, direction: SortDirection.Asc },
{ page: 1, perPage: 25 }
);
});
it('sorts by indicator type in descending order', async () => {
const slo = createSLO();
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
mockSummaryClient.fetchSummary.mockResolvedValueOnce(someSummary(slo));
await findSLO.execute({ sortBy: 'indicatorType', sortDirection: 'desc' });
expect(mockRepository.find).toHaveBeenCalledWith(
{ name: undefined },
{ field: SortField.IndicatorType, direction: SortDirection.Desc },
{ page: 1, perPage: 25 }
);
expect(mockSummarySearchClient.search.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"slo.name:'Service*' and slo.indicator.type:'sli.kql.custom'",
Object {
"direction": "asc",
"field": "error_budget_consumed",
},
Object {
"page": 2,
"perPage": 10,
},
]
`);
});
});
});
function someSummary(slo: SLO): Record<SLOId, Summary> {
function summarySearchResult(slo: SLO): Paginated<SLOSummary> {
return {
[slo.id]: {
status: 'HEALTHY',
sliValue: 0.9999,
errorBudget: {
initial: 0.001,
consumed: 0.1,
remaining: 0.9,
isEstimated: false,
total: 1,
perPage: 25,
page: 1,
results: [
{
id: slo.id,
summary: {
status: 'HEALTHY',
sliValue: 0.9999,
errorBudget: {
initial: 0.001,
consumed: 0.1,
remaining: 0.9,
isEstimated: false,
},
},
},
},
],
};
}

View file

@ -6,55 +6,45 @@
*/
import { FindSLOParams, FindSLOResponse, findSLOResponseSchema } from '@kbn/slo-schema';
import { SLO, SLOId, SLOWithSummary, Summary } from '../../domain/models';
import {
Criteria,
Paginated,
Pagination,
SLORepository,
Sort,
SortField,
SortDirection,
} from './slo_repository';
import { SummaryClient } from './summary_client';
import { SLO, SLOWithSummary } from '../../domain/models';
import { SLORepository } from './slo_repository';
import { Pagination, SLOSummary, Sort, SummarySearchClient } from './summary_search_client';
const DEFAULT_PAGE = 1;
const DEFAULT_PER_PAGE = 25;
export class FindSLO {
constructor(private repository: SLORepository, private summaryClient: SummaryClient) {}
constructor(
private repository: SLORepository,
private summarySearchClient: SummarySearchClient
) {}
public async execute(params: FindSLOParams): Promise<FindSLOResponse> {
const pagination: Pagination = toPagination(params);
const criteria: Criteria = toCriteria(params);
const sort: Sort = toSort(params);
const { results: sloList, ...resultMeta }: Paginated<SLO> = await this.repository.find(
criteria,
sort,
pagination
const sloSummaryList = await this.summarySearchClient.search(
params.kqlQuery ?? '',
toSort(params),
toPagination(params)
);
const summaryBySlo = await this.summaryClient.fetchSummary(sloList);
const sloListWithSummary = mergeSloWithSummary(sloList, summaryBySlo);
const sloList = await this.repository.findAllByIds(sloSummaryList.results.map((slo) => slo.id));
const sloListWithSummary = mergeSloWithSummary(sloList, sloSummaryList.results);
return findSLOResponseSchema.encode({
page: resultMeta.page,
perPage: resultMeta.perPage,
total: resultMeta.total,
page: sloSummaryList.page,
perPage: sloSummaryList.perPage,
total: sloSummaryList.total,
results: sloListWithSummary,
});
}
}
function mergeSloWithSummary(
sloList: SLO[],
summaryBySlo: Record<SLOId, Summary>
): SLOWithSummary[] {
return sloList.map((slo) => ({
...slo,
summary: summaryBySlo[slo.id],
}));
function mergeSloWithSummary(sloList: SLO[], sloSummaryList: SLOSummary[]): SLOWithSummary[] {
return sloSummaryList
.filter((sloSummary) => sloList.some((s) => s.id === sloSummary.id))
.map((sloSummary) => ({
...sloList.find((s) => s.id === sloSummary.id)!,
summary: sloSummary.summary,
}));
}
function toPagination(params: FindSLOParams): Pagination {
@ -67,13 +57,9 @@ function toPagination(params: FindSLOParams): Pagination {
};
}
function toCriteria(params: FindSLOParams): Criteria {
return { name: params.name, indicatorTypes: params.indicatorTypes };
}
function toSort(params: FindSLOParams): Sort {
return {
field: params.sortBy === 'indicatorType' ? SortField.IndicatorType : SortField.CreationTime,
direction: params.sortDirection === 'desc' ? SortDirection.Desc : SortDirection.Asc,
field: params.sortBy ?? 'status',
direction: params.sortDirection ?? 'asc',
};
}

View file

@ -7,14 +7,14 @@
import { Duration, DurationUnit } from '../../../domain/models';
export function oneQuarter(): Duration {
return new Duration(1, DurationUnit.Quarter);
}
export function thirtyDays(): Duration {
return new Duration(30, DurationUnit.Day);
}
export function ninetyDays(): Duration {
return new Duration(90, DurationUnit.Day);
}
export function oneMonth(): Duration {
return new Duration(1, DurationUnit.Month);
}

View file

@ -5,12 +5,10 @@
* 2.0.
*/
import { SavedObject } from '@kbn/core-saved-objects-server';
import { CreateSLOParams, HistogramIndicator, sloSchema } from '@kbn/slo-schema';
import { cloneDeep } from 'lodash';
import { v1 as uuidv1 } from 'uuid';
import { SavedObject } from '@kbn/core-saved-objects-server';
import { sloSchema, CreateSLOParams, HistogramIndicator } from '@kbn/slo-schema';
import { SO_SLO_TYPE } from '../../../saved_objects';
import {
APMTransactionDurationIndicator,
APMTransactionErrorRateIndicator,
@ -22,9 +20,9 @@ import {
SLO,
StoredSLO,
} from '../../../domain/models';
import { Paginated } from '../slo_repository';
import { oneWeek, twoMinute } from './duration';
import { sevenDaysRolling } from './time_window';
import { SO_SLO_TYPE } from '../../../saved_objects';
import { twoMinute } from './duration';
import { sevenDaysRolling, weeklyCalendarAligned } from './time_window';
export const createAPMTransactionErrorRateIndicator = (
params: Partial<APMTransactionErrorRateIndicator['params']> = {}
@ -184,23 +182,7 @@ export const createSLOWithTimeslicesBudgetingMethod = (params: Partial<SLO> = {}
export const createSLOWithCalendarTimeWindow = (params: Partial<SLO> = {}): SLO => {
return createSLO({
timeWindow: {
duration: oneWeek(),
type: 'calendarAligned',
},
timeWindow: weeklyCalendarAligned(),
...params,
});
};
export const createPaginatedSLO = (
slo: SLO,
params: Partial<Paginated<SLO>> = {}
): Paginated<SLO> => {
return {
page: 1,
perPage: 25,
total: 1,
results: [slo],
...params,
};
};

View file

@ -0,0 +1,71 @@
/*
* 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 { v1 as uuidv1 } from 'uuid';
export const aSummaryDocument = ({
id = uuidv1(),
sliValue = 0.9,
consumed = 0.4,
isTempDoc = false,
status = 'HEALTHY',
} = {}) => {
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: '*',
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,
};
};
export const aHitFromSummaryIndex = (_source: any) => {
return {
_index: '.slo-observability.summary-v2',
_id: uuidv1(),
_score: 1,
_source,
};
};
export const aHitFromTempSummaryIndex = (_source: any) => {
return {
_index: '.slo-observability.summary-v2.temp',
_id: uuidv1(),
_score: 1,
_source,
};
};

View file

@ -5,15 +5,12 @@
* 2.0.
*/
import { RollingTimeWindow, TimeWindow } from '../../../domain/models/time_window';
import { oneWeek, sevenDays, sixHours, thirtyDays } from './duration';
export function sixHoursRolling(): TimeWindow {
return {
duration: sixHours(),
type: 'rolling',
};
}
import {
CalendarAlignedTimeWindow,
RollingTimeWindow,
TimeWindow,
} from '../../../domain/models/time_window';
import { ninetyDays, oneMonth, oneWeek, sevenDays, thirtyDays } from './duration';
export function sevenDaysRolling(): RollingTimeWindow {
return {
@ -28,9 +25,23 @@ export function thirtyDaysRolling(): RollingTimeWindow {
};
}
export function weeklyCalendarAligned(): TimeWindow {
export function ninetyDaysRolling(): TimeWindow {
return {
duration: ninetyDays(),
type: 'rolling',
};
}
export function weeklyCalendarAligned(): CalendarAlignedTimeWindow {
return {
duration: oneWeek(),
type: 'calendarAligned',
};
}
export function monthlyCalendarAligned(): CalendarAlignedTimeWindow {
return {
duration: oneMonth(),
type: 'calendarAligned',
};
}

View file

@ -6,18 +6,20 @@
*/
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { LicensingApiRequestHandlerContext } from '@kbn/licensing-plugin/server';
import {
getSLOTransformId,
SLO_COMPONENT_TEMPLATE_MAPPINGS_NAME,
SLO_COMPONENT_TEMPLATE_SETTINGS_NAME,
SLO_INDEX_TEMPLATE_NAME,
SLO_INGEST_PIPELINE_NAME,
SLO_SUMMARY_COMPONENT_TEMPLATE_MAPPINGS_NAME,
SLO_SUMMARY_COMPONENT_TEMPLATE_SETTINGS_NAME,
SLO_SUMMARY_INDEX_TEMPLATE_NAME,
SLO_SUMMARY_TRANSFORM_NAME_PREFIX,
} from '../../assets/constants';
import { StoredSLO } from '../../domain/models';
import { SO_SLO_TYPE } from '../../saved_objects';
import { SLO } from '../../domain/models';
import { SLORepository } from './slo_repository';
const OK = 'OK';
const NOT_OK = 'NOT_OK';
@ -30,11 +32,19 @@ export async function getGlobalDiagnosis(
const licenseInfo = licensing.license.toJSON();
const userPrivileges = await esClient.security.getUserPrivileges();
const sloResources = await getSloResourcesDiagnosis(esClient);
const sloSummaryResources = await getSloSummaryResourcesDiagnosis(esClient);
const sloSummaryTransformsStats = await esClient.transform.getTransformStats({
transform_id: `${SLO_SUMMARY_TRANSFORM_NAME_PREFIX}*`,
allow_no_match: true,
});
return {
licenseAndFeatures: licenseInfo,
userPrivileges,
sloResources,
sloSummaryResources,
sloSummaryTransformsStats,
};
} catch (error) {
throw error;
@ -43,42 +53,36 @@ export async function getGlobalDiagnosis(
export async function getSloDiagnosis(
sloId: string,
services: { esClient: ElasticsearchClient; soClient: SavedObjectsClientContract }
services: { esClient: ElasticsearchClient; repository: SLORepository }
) {
const { esClient, soClient } = services;
const { esClient, repository } = services;
const sloResources = await getSloResourcesDiagnosis(esClient);
const sloSummaryResources = await getSloSummaryResourcesDiagnosis(esClient);
let sloSavedObject;
let slo: SLO | undefined;
try {
sloSavedObject = await soClient.get<StoredSLO>(SO_SLO_TYPE, sloId);
slo = await repository.findById(sloId);
} catch (err) {
// noop
}
const sloTransformStats = await esClient.transform.getTransformStats({
transform_id: getSLOTransformId(sloId, sloSavedObject?.attributes.revision ?? 1),
transform_id: getSLOTransformId(sloId, slo?.revision ?? 1),
allow_no_match: true,
});
let dataSample;
if (sloSavedObject?.attributes.indicator.params.index) {
const slo = sloSavedObject.attributes;
const sortField =
'timestampField' in slo.indicator.params
? slo.indicator.params.timestampField ?? '@timestamp'
: '@timestamp';
dataSample = await esClient.search({
index: slo.indicator.params.index,
sort: { [sortField]: 'desc' },
size: 5,
});
}
const sloSummaryTransformsStats = await esClient.transform.getTransformStats({
transform_id: `${SLO_SUMMARY_TRANSFORM_NAME_PREFIX}*`,
allow_no_match: true,
});
return {
sloResources,
sloSavedObject: sloSavedObject ?? NOT_OK,
sloSummaryResources,
slo: slo ?? NOT_OK,
sloTransformStats,
dataSample: dataSample ?? NOT_OK,
sloSummaryTransformsStats,
};
}
@ -116,3 +120,29 @@ async function getSloResourcesDiagnosis(esClient: ElasticsearchClient) {
}
}
}
async function getSloSummaryResourcesDiagnosis(esClient: ElasticsearchClient) {
try {
const indexTemplateExists = await esClient.indices.existsIndexTemplate({
name: SLO_SUMMARY_INDEX_TEMPLATE_NAME,
});
const mappingsTemplateExists = await esClient.cluster.existsComponentTemplate({
name: SLO_SUMMARY_COMPONENT_TEMPLATE_MAPPINGS_NAME,
});
const settingsTemplateExists = await esClient.cluster.existsComponentTemplate({
name: SLO_SUMMARY_COMPONENT_TEMPLATE_SETTINGS_NAME,
});
return {
[SLO_SUMMARY_INDEX_TEMPLATE_NAME]: indexTemplateExists ? OK : NOT_OK,
[SLO_SUMMARY_COMPONENT_TEMPLATE_MAPPINGS_NAME]: mappingsTemplateExists ? OK : NOT_OK,
[SLO_SUMMARY_COMPONENT_TEMPLATE_SETTINGS_NAME]: settingsTemplateExists ? OK : NOT_OK,
};
} catch (err) {
if (err.meta.statusCode === 403) {
throw new Error('Insufficient permissions to access Elasticsearch Cluster', { cause: err });
}
}
}

View file

@ -12,6 +12,8 @@ export * from './find_slo';
export * from './get_slo';
export * from './historical_summary_client';
export * from './resource_installer';
export * from './slo_installer';
export * from './summary_transform/summary_transform_installer';
export * from './sli_client';
export * from './slo_repository';
export * from './transform_manager';

View file

@ -9,6 +9,8 @@ import { ResourceInstaller } from '../resource_installer';
import { SLIClient } from '../sli_client';
import { SLORepository } from '../slo_repository';
import { SummaryClient } from '../summary_client';
import { SummarySearchClient } from '../summary_search_client';
import { SummaryTransformInstaller } from '../summary_transform/summary_transform_installer';
import { TransformManager } from '../transform_manager';
const createResourceInstallerMock = (): jest.Mocked<ResourceInstaller> => {
@ -17,6 +19,12 @@ const createResourceInstallerMock = (): jest.Mocked<ResourceInstaller> => {
};
};
const createSummaryTransformInstallerMock = (): jest.Mocked<SummaryTransformInstaller> => {
return {
installAndStart: jest.fn(),
};
};
const createTransformManagerMock = (): jest.Mocked<TransformManager> => {
return {
install: jest.fn(),
@ -32,7 +40,6 @@ const createSLORepositoryMock = (): jest.Mocked<SLORepository> => {
findById: jest.fn(),
findAllByIds: jest.fn(),
deleteById: jest.fn(),
find: jest.fn(),
};
};
@ -42,6 +49,12 @@ const createSummaryClientMock = (): jest.Mocked<SummaryClient> => {
};
};
const createSummarySearchClientMock = (): jest.Mocked<SummarySearchClient> => {
return {
search: jest.fn(),
};
};
const createSLIClientMock = (): jest.Mocked<SLIClient> => {
return {
fetchSLIDataFrom: jest.fn(),
@ -50,8 +63,10 @@ const createSLIClientMock = (): jest.Mocked<SLIClient> => {
export {
createResourceInstallerMock,
createSummaryTransformInstallerMock,
createTransformManagerMock,
createSLORepositoryMock,
createSummaryClientMock,
createSummarySearchClientMock,
createSLIClientMock,
};

View file

@ -5,28 +5,31 @@
* 2.0.
*/
import { IngestGetPipelineResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { loggerMock } from '@kbn/logging-mocks';
import {
SLO_INGEST_PIPELINE_NAME,
SLO_COMPONENT_TEMPLATE_MAPPINGS_NAME,
SLO_COMPONENT_TEMPLATE_SETTINGS_NAME,
SLO_INDEX_TEMPLATE_NAME,
SLO_INGEST_PIPELINE_NAME,
SLO_RESOURCES_VERSION,
SLO_SUMMARY_COMPONENT_TEMPLATE_MAPPINGS_NAME,
SLO_SUMMARY_COMPONENT_TEMPLATE_SETTINGS_NAME,
SLO_SUMMARY_INDEX_TEMPLATE_NAME,
SLO_SUMMARY_INGEST_PIPELINE_NAME,
} from '../../assets/constants';
import { DefaultResourceInstaller } from './resource_installer';
describe('resourceInstaller', () => {
describe("when the common resources don't exist", () => {
describe('when the common resources are not installed yet', () => {
it('installs the common resources', async () => {
const mockClusterClient = elasticsearchServiceMock.createElasticsearchClient();
mockClusterClient.indices.existsIndexTemplate.mockResponseOnce(false);
mockClusterClient.indices.getIndexTemplate.mockResponseOnce({ index_templates: [] });
const installer = new DefaultResourceInstaller(mockClusterClient, loggerMock.create());
await installer.ensureCommonResourcesInstalled();
expect(mockClusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(2);
expect(mockClusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4);
expect(mockClusterClient.cluster.putComponentTemplate).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ name: SLO_COMPONENT_TEMPLATE_MAPPINGS_NAME })
@ -35,23 +38,71 @@ describe('resourceInstaller', () => {
2,
expect.objectContaining({ name: SLO_COMPONENT_TEMPLATE_SETTINGS_NAME })
);
expect(mockClusterClient.indices.putIndexTemplate).toHaveBeenCalledWith(
expect(mockClusterClient.cluster.putComponentTemplate).toHaveBeenNthCalledWith(
3,
expect.objectContaining({ name: SLO_SUMMARY_COMPONENT_TEMPLATE_MAPPINGS_NAME })
);
expect(mockClusterClient.cluster.putComponentTemplate).toHaveBeenNthCalledWith(
4,
expect.objectContaining({ name: SLO_SUMMARY_COMPONENT_TEMPLATE_SETTINGS_NAME })
);
expect(mockClusterClient.indices.putIndexTemplate).toHaveBeenCalledTimes(2);
expect(mockClusterClient.indices.putIndexTemplate).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ name: SLO_INDEX_TEMPLATE_NAME })
);
expect(mockClusterClient.ingest.putPipeline).toHaveBeenCalledWith(
expect(mockClusterClient.indices.putIndexTemplate).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ name: SLO_SUMMARY_INDEX_TEMPLATE_NAME })
);
expect(mockClusterClient.ingest.putPipeline).toHaveBeenCalledTimes(2);
expect(mockClusterClient.ingest.putPipeline).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ id: SLO_INGEST_PIPELINE_NAME })
);
expect(mockClusterClient.ingest.putPipeline).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ id: SLO_SUMMARY_INGEST_PIPELINE_NAME })
);
});
});
describe('when the common resources exist', () => {
it('does not install the common resources', async () => {
describe('when the common resources are already installed', () => {
it('skips the installation', async () => {
const mockClusterClient = elasticsearchServiceMock.createElasticsearchClient();
mockClusterClient.indices.existsIndexTemplate.mockResponseOnce(true);
mockClusterClient.indices.getIndexTemplate.mockResponseOnce({
index_templates: [
{
name: SLO_INDEX_TEMPLATE_NAME,
index_template: {
index_patterns: [],
composed_of: [],
_meta: { version: SLO_RESOURCES_VERSION },
},
},
],
});
mockClusterClient.indices.getIndexTemplate.mockResponseOnce({
index_templates: [
{
name: SLO_SUMMARY_INDEX_TEMPLATE_NAME,
index_template: {
index_patterns: [],
composed_of: [],
_meta: { version: SLO_RESOURCES_VERSION },
},
},
],
});
mockClusterClient.ingest.getPipeline.mockResponseOnce({
// @ts-ignore _meta not typed properly
[SLO_INGEST_PIPELINE_NAME]: { _meta: { version: SLO_RESOURCES_VERSION } },
} as IngestGetPipelineResponse);
});
mockClusterClient.ingest.getPipeline.mockResponseOnce({
// @ts-ignore _meta not typed properly
[SLO_SUMMARY_INGEST_PIPELINE_NAME]: { _meta: { version: SLO_RESOURCES_VERSION } },
});
const installer = new DefaultResourceInstaller(mockClusterClient, loggerMock.create());
await installer.ensureCommonResourcesInstalled();

View file

@ -11,18 +11,32 @@ import type {
IngestPutPipelineRequest,
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
import {
SLO_INGEST_PIPELINE_NAME,
SLO_COMPONENT_TEMPLATE_MAPPINGS_NAME,
SLO_COMPONENT_TEMPLATE_SETTINGS_NAME,
SLO_INDEX_TEMPLATE_NAME,
SLO_RESOURCES_VERSION,
} from '../../assets/constants';
import { getSLOMappingsTemplate } from '../../assets/component_templates/slo_mappings_template';
import { getSLOSettingsTemplate } from '../../assets/component_templates/slo_settings_template';
import { getSLOSummaryMappingsTemplate } from '../../assets/component_templates/slo_summary_mappings_template';
import { getSLOSummarySettingsTemplate } from '../../assets/component_templates/slo_summary_settings_template';
import {
SLO_COMPONENT_TEMPLATE_MAPPINGS_NAME,
SLO_COMPONENT_TEMPLATE_SETTINGS_NAME,
SLO_DESTINATION_INDEX_NAME,
SLO_INDEX_TEMPLATE_NAME,
SLO_INDEX_TEMPLATE_PATTERN,
SLO_INGEST_PIPELINE_INDEX_NAME_PREFIX,
SLO_INGEST_PIPELINE_NAME,
SLO_RESOURCES_VERSION,
SLO_SUMMARY_COMPONENT_TEMPLATE_MAPPINGS_NAME,
SLO_SUMMARY_COMPONENT_TEMPLATE_SETTINGS_NAME,
SLO_SUMMARY_DESTINATION_INDEX_NAME,
SLO_SUMMARY_INDEX_TEMPLATE_NAME,
SLO_SUMMARY_INDEX_TEMPLATE_PATTERN,
SLO_SUMMARY_INGEST_PIPELINE_NAME,
SLO_SUMMARY_TEMP_INDEX_NAME,
} from '../../assets/constants';
import { getSLOIndexTemplate } from '../../assets/index_templates/slo_index_templates';
import { getSLOSummaryIndexTemplate } from '../../assets/index_templates/slo_summary_index_templates';
import { getSLOPipelineTemplate } from '../../assets/ingest_templates/slo_pipeline_template';
import { getSLOSummaryPipelineTemplate } from '../../assets/ingest_templates/slo_summary_pipeline_template';
import { retryTransientEsErrors } from '../../utils/retry';
export interface ResourceInstaller {
ensureCommonResourcesInstalled(): Promise<void>;
@ -35,13 +49,12 @@ export class DefaultResourceInstaller implements ResourceInstaller {
const alreadyInstalled = await this.areResourcesAlreadyInstalled();
if (alreadyInstalled) {
this.logger.debug(
`Skipping installation of resources shared for SLO since they already exist`
);
this.logger.info('SLO resources already installed - skipping');
return;
}
try {
this.logger.info('Installing SLO shared resources');
await Promise.all([
this.createOrUpdateComponentTemplate(
getSLOMappingsTemplate(SLO_COMPONENT_TEMPLATE_MAPPINGS_NAME)
@ -49,41 +62,90 @@ export class DefaultResourceInstaller implements ResourceInstaller {
this.createOrUpdateComponentTemplate(
getSLOSettingsTemplate(SLO_COMPONENT_TEMPLATE_SETTINGS_NAME)
),
this.createOrUpdateComponentTemplate(
getSLOSummaryMappingsTemplate(SLO_SUMMARY_COMPONENT_TEMPLATE_MAPPINGS_NAME)
),
this.createOrUpdateComponentTemplate(
getSLOSummarySettingsTemplate(SLO_SUMMARY_COMPONENT_TEMPLATE_SETTINGS_NAME)
),
]);
await this.createOrUpdateIndexTemplate(
getSLOIndexTemplate(SLO_INDEX_TEMPLATE_NAME, `${SLO_INDEX_TEMPLATE_NAME}-*`, [
getSLOIndexTemplate(SLO_INDEX_TEMPLATE_NAME, SLO_INDEX_TEMPLATE_PATTERN, [
SLO_COMPONENT_TEMPLATE_MAPPINGS_NAME,
SLO_COMPONENT_TEMPLATE_SETTINGS_NAME,
])
);
await this.createOrUpdateIngestPipelineTemplate(
getSLOPipelineTemplate(
SLO_INGEST_PIPELINE_NAME,
this.getPipelinePrefix(SLO_RESOURCES_VERSION)
await this.createOrUpdateIndexTemplate(
getSLOSummaryIndexTemplate(
SLO_SUMMARY_INDEX_TEMPLATE_NAME,
SLO_SUMMARY_INDEX_TEMPLATE_PATTERN,
[
SLO_SUMMARY_COMPONENT_TEMPLATE_MAPPINGS_NAME,
SLO_SUMMARY_COMPONENT_TEMPLATE_SETTINGS_NAME,
]
)
);
await this.createIndex(SLO_DESTINATION_INDEX_NAME);
await this.createIndex(SLO_SUMMARY_DESTINATION_INDEX_NAME);
await this.createIndex(SLO_SUMMARY_TEMP_INDEX_NAME);
await this.createOrUpdateIngestPipelineTemplate(
getSLOPipelineTemplate(SLO_INGEST_PIPELINE_NAME, SLO_INGEST_PIPELINE_INDEX_NAME_PREFIX)
);
await this.createOrUpdateIngestPipelineTemplate(
getSLOSummaryPipelineTemplate(SLO_SUMMARY_INGEST_PIPELINE_NAME)
);
} catch (err) {
this.logger.error(`Error installing resources shared for SLO - ${err.message}`);
this.logger.error(`Error installing resources shared for SLO: ${err.message}`);
throw err;
}
}
private getPipelinePrefix(version: number): string {
// Following https://www.elastic.co/blog/an-introduction-to-the-elastic-data-stream-naming-scheme
// slo-observability.sli-<version>.<index-date>
return `${SLO_INDEX_TEMPLATE_NAME}-v${version}.`;
}
private async areResourcesAlreadyInstalled(): Promise<boolean> {
const indexTemplateExists = await this.esClient.indices.existsIndexTemplate({
name: SLO_INDEX_TEMPLATE_NAME,
});
let indexTemplateExists = false;
try {
const { index_templates: indexTemplates } = await this.execute(() =>
this.esClient.indices.getIndexTemplate({
name: SLO_INDEX_TEMPLATE_NAME,
})
);
const sloIndexTemplate = indexTemplates.find(
(template) => template.name === SLO_INDEX_TEMPLATE_NAME
);
indexTemplateExists =
!!sloIndexTemplate &&
sloIndexTemplate.index_template._meta?.version === SLO_RESOURCES_VERSION;
} catch (err) {
return false;
}
let summaryIndexTemplateExists = false;
try {
const { index_templates: indexTemplates } = await this.execute(() =>
this.esClient.indices.getIndexTemplate({
name: SLO_SUMMARY_INDEX_TEMPLATE_NAME,
})
);
const sloSummaryIndexTemplate = indexTemplates.find(
(template) => template.name === SLO_SUMMARY_INDEX_TEMPLATE_NAME
);
summaryIndexTemplateExists =
!!sloSummaryIndexTemplate &&
sloSummaryIndexTemplate.index_template._meta?.version === SLO_RESOURCES_VERSION;
} catch (err) {
return false;
}
let ingestPipelineExists = false;
try {
const pipeline = await this.esClient.ingest.getPipeline({ id: SLO_INGEST_PIPELINE_NAME });
const pipeline = await this.execute(() =>
this.esClient.ingest.getPipeline({ id: SLO_INGEST_PIPELINE_NAME })
);
ingestPipelineExists =
// @ts-ignore _meta is not defined on the type
@ -92,21 +154,54 @@ export class DefaultResourceInstaller implements ResourceInstaller {
return false;
}
return indexTemplateExists && ingestPipelineExists;
let summaryIngestPipelineExists = false;
try {
const pipeline = await this.execute(() =>
this.esClient.ingest.getPipeline({ id: SLO_SUMMARY_INGEST_PIPELINE_NAME })
);
summaryIngestPipelineExists =
pipeline &&
// @ts-ignore _meta is not defined on the type
pipeline[SLO_SUMMARY_INGEST_PIPELINE_NAME]._meta.version === SLO_RESOURCES_VERSION;
} catch (err) {
return false;
}
return (
indexTemplateExists &&
summaryIndexTemplateExists &&
ingestPipelineExists &&
summaryIngestPipelineExists
);
}
private async createOrUpdateComponentTemplate(template: ClusterPutComponentTemplateRequest) {
this.logger.debug(`Installing SLO component template ${template.name}`);
return this.esClient.cluster.putComponentTemplate(template);
this.logger.info(`Installing SLO component template [${template.name}]`);
return this.execute(() => this.esClient.cluster.putComponentTemplate(template));
}
private async createOrUpdateIndexTemplate(template: IndicesPutIndexTemplateRequest) {
this.logger.debug(`Installing SLO index template ${template.name}`);
return this.esClient.indices.putIndexTemplate(template);
this.logger.info(`Installing SLO index template [${template.name}]`);
return this.execute(() => this.esClient.indices.putIndexTemplate(template));
}
private async createOrUpdateIngestPipelineTemplate(template: IngestPutPipelineRequest) {
this.logger.debug(`Installing SLO ingest pipeline template ${template.id}`);
await this.esClient.ingest.putPipeline(template);
this.logger.info(`Installing SLO ingest pipeline [${template.id}]`);
return this.execute(() => this.esClient.ingest.putPipeline(template));
}
private async createIndex(indexName: string) {
try {
await this.execute(() => this.esClient.indices.create({ index: indexName }));
} catch (err) {
if (err?.meta?.body?.error?.type !== 'resource_already_exists_exception') {
throw err;
}
}
}
private async execute<T>(esCall: () => Promise<T>): Promise<T> {
return await retryTransientEsErrors(esCall, { logger: this.logger });
}
}

View file

@ -13,11 +13,15 @@ import {
MsearchMultisearchBody,
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { ElasticsearchClient } from '@kbn/core/server';
import { occurrencesBudgetingMethodSchema, timeslicesBudgetingMethodSchema } from '@kbn/slo-schema';
import {
occurrencesBudgetingMethodSchema,
timeslicesBudgetingMethodSchema,
toMomentUnitOfTime,
} from '@kbn/slo-schema';
import { assertNever } from '@kbn/std';
import moment from 'moment';
import { SLO_DESTINATION_INDEX_PATTERN } from '../../assets/constants';
import { DateRange, Duration, IndicatorData, SLO } from '../../domain/models';
import { toDateRange } from '../../domain/services/date_range';
import { InternalQueryError } from '../../errors';
export interface SLIClient {
@ -47,10 +51,7 @@ export class DefaultSLIClient implements SLIClient {
a.duration.isShorterThan(b.duration) ? 1 : -1
);
const longestLookbackWindow = sortedLookbackWindows[0];
const longestDateRange = toDateRange({
duration: longestLookbackWindow.duration,
type: 'rolling',
});
const longestDateRange = getLookbackDateRange(longestLookbackWindow.duration);
if (occurrencesBudgetingMethodSchema.is(slo.budgetingMethod)) {
const result = await this.esClient.search<unknown, EsAggregations>({
@ -179,3 +180,15 @@ function handleWindowedResult(
return indicatorDataPerLookbackWindow;
}
function getLookbackDateRange(duration: Duration): { from: Date; to: Date } {
const unit = toMomentUnitOfTime(duration.unit);
const now = moment.utc().startOf('minute');
const from = now.clone().subtract(duration.value, unit);
const to = now.clone();
return {
from: from.toDate(),
to: to.toDate(),
};
}

View file

@ -0,0 +1,34 @@
/*
* 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 { loggingSystemMock } from '@kbn/core/server/mocks';
import { MockedLogger } from '@kbn/logging-mocks';
import { createResourceInstallerMock, createSummaryTransformInstallerMock } from './mocks';
import { DefaultSLOInstaller } from './slo_installer';
describe('SLO Installer', () => {
let loggerMock: jest.Mocked<MockedLogger>;
beforeEach(() => {
loggerMock = loggingSystemMock.createLogger();
});
it.skip('handles concurrent installation', async () => {
const resourceInstaller = createResourceInstallerMock();
const summaryTransformInstaller = createSummaryTransformInstallerMock();
const service = new DefaultSLOInstaller(
resourceInstaller,
summaryTransformInstaller,
loggerMock
);
await Promise.all([service.install(), service.install()]);
expect(resourceInstaller.ensureCommonResourcesInstalled).toHaveBeenCalledTimes(1);
expect(summaryTransformInstaller.installAndStart).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,46 @@
/*
* 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 { Logger } from '@kbn/core/server';
import { ResourceInstaller, SummaryTransformInstaller } from '.';
export interface SLOInstaller {
install(): Promise<void>;
}
export class DefaultSLOInstaller implements SLOInstaller {
private isInstalling: boolean = false;
constructor(
private sloResourceInstaller: ResourceInstaller,
private sloSummaryInstaller: SummaryTransformInstaller,
private logger: Logger
) {}
public async install() {
if (this.isInstalling || process.env.CI) {
return;
}
this.isInstalling = true;
let installTimeout;
try {
installTimeout = setTimeout(() => (this.isInstalling = false), 60000);
await this.sloResourceInstaller.ensureCommonResourcesInstalled();
await this.sloSummaryInstaller.installAndStart();
} catch (error) {
this.logger.error('Failed to install SLO common resources and summary transforms', {
error,
});
throw error;
} finally {
this.isInstalling = false;
clearTimeout(installTimeout);
}
}
}

View file

@ -8,23 +8,16 @@
import { SavedObjectsClientContract, SavedObjectsFindResponse } from '@kbn/core/server';
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import { sloSchema } from '@kbn/slo-schema';
import { SLO, StoredSLO } from '../../domain/models';
import { SO_SLO_TYPE } from '../../saved_objects';
import {
KibanaSavedObjectsSLORepository,
Pagination,
Sort,
SortDirection,
SortField,
} from './slo_repository';
import { createAPMTransactionDurationIndicator, createSLO, aStoredSLO } from './fixtures/slo';
import { SLOIdConflict, SLONotFound } from '../../errors';
import { SO_SLO_TYPE } from '../../saved_objects';
import { aStoredSLO, createAPMTransactionDurationIndicator, createSLO } from './fixtures/slo';
import { KibanaSavedObjectsSLORepository } from './slo_repository';
const SOME_SLO = createSLO({ indicator: createAPMTransactionDurationIndicator() });
const ANOTHER_SLO = createSLO();
function createFindResponse(sloList: SLO[]): SavedObjectsFindResponse<StoredSLO> {
function soFindResponse(sloList: SLO[]): SavedObjectsFindResponse<StoredSLO> {
return {
page: 1,
per_page: 25,
@ -48,7 +41,7 @@ describe('KibanaSavedObjectsSLORepository', () => {
describe('validation', () => {
it('findById throws when an SLO is not found', async () => {
soClientMock.find.mockResolvedValueOnce(createFindResponse([]));
soClientMock.find.mockResolvedValueOnce(soFindResponse([]));
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
await expect(repository.findById('inexistant-slo-id')).rejects.toThrowError(
@ -57,7 +50,7 @@ describe('KibanaSavedObjectsSLORepository', () => {
});
it('deleteById throws when an SLO is not found', async () => {
soClientMock.find.mockResolvedValueOnce(createFindResponse([]));
soClientMock.find.mockResolvedValueOnce(soFindResponse([]));
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
await expect(repository.deleteById('inexistant-slo-id')).rejects.toThrowError(
@ -69,7 +62,7 @@ describe('KibanaSavedObjectsSLORepository', () => {
describe('saving an SLO', () => {
it('saves the new SLO', async () => {
const slo = createSLO({ id: 'my-id' });
soClientMock.find.mockResolvedValueOnce(createFindResponse([]));
soClientMock.find.mockResolvedValueOnce(soFindResponse([]));
soClientMock.create.mockResolvedValueOnce(aStoredSLO(slo));
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
@ -90,7 +83,7 @@ describe('KibanaSavedObjectsSLORepository', () => {
it('throws when the SLO id already exists and "throwOnConflict" is true', async () => {
const slo = createSLO({ id: 'my-id' });
soClientMock.find.mockResolvedValueOnce(createFindResponse([slo]));
soClientMock.find.mockResolvedValueOnce(soFindResponse([slo]));
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
await expect(repository.save(slo, { throwOnConflict: true })).rejects.toThrowError(
@ -106,7 +99,7 @@ describe('KibanaSavedObjectsSLORepository', () => {
it('updates the existing SLO', async () => {
const slo = createSLO({ id: 'my-id' });
soClientMock.find.mockResolvedValueOnce(createFindResponse([slo]));
soClientMock.find.mockResolvedValueOnce(soFindResponse([slo]));
soClientMock.create.mockResolvedValueOnce(aStoredSLO(slo));
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
@ -128,7 +121,7 @@ describe('KibanaSavedObjectsSLORepository', () => {
it('finds an existing SLO', async () => {
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
soClientMock.find.mockResolvedValueOnce(createFindResponse([SOME_SLO]));
soClientMock.find.mockResolvedValueOnce(soFindResponse([SOME_SLO]));
const foundSLO = await repository.findById(SOME_SLO.id);
@ -143,7 +136,7 @@ describe('KibanaSavedObjectsSLORepository', () => {
it('finds all SLOs by ids', async () => {
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
soClientMock.find.mockResolvedValueOnce(createFindResponse([SOME_SLO, ANOTHER_SLO]));
soClientMock.find.mockResolvedValueOnce(soFindResponse([SOME_SLO, ANOTHER_SLO]));
const results = await repository.findAllByIds([SOME_SLO.id, ANOTHER_SLO.id]);
@ -158,7 +151,7 @@ describe('KibanaSavedObjectsSLORepository', () => {
it('deletes an SLO', async () => {
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
soClientMock.find.mockResolvedValueOnce(createFindResponse([SOME_SLO]));
soClientMock.find.mockResolvedValueOnce(soFindResponse([SOME_SLO]));
await repository.deleteById(SOME_SLO.id);
@ -170,238 +163,4 @@ describe('KibanaSavedObjectsSLORepository', () => {
});
expect(soClientMock.delete).toHaveBeenCalledWith(SO_SLO_TYPE, SOME_SLO.id);
});
describe('find', () => {
const DEFAULT_PAGINATION: Pagination = { page: 1, perPage: 25 };
const DEFAULT_SORTING: Sort = {
field: SortField.CreationTime,
direction: SortDirection.Asc,
};
describe('Name search', () => {
it('includes the search on name with wildcard when provided', async () => {
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
soClientMock.find.mockResolvedValueOnce(createFindResponse([SOME_SLO]));
const result = await repository.find(
{ name: 'availability*' },
DEFAULT_SORTING,
DEFAULT_PAGINATION
);
expect(result).toEqual({
page: 1,
perPage: 25,
total: 1,
results: [SOME_SLO],
});
expect(soClientMock.find).toHaveBeenCalledWith({
type: SO_SLO_TYPE,
page: 1,
perPage: 25,
filter: undefined,
search: '*availability*',
searchFields: ['name'],
sortField: 'created_at',
sortOrder: 'asc',
});
});
it('includes the search on name with added wildcard when not provided', async () => {
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
soClientMock.find.mockResolvedValueOnce(createFindResponse([SOME_SLO]));
const result = await repository.find(
{ name: 'availa' },
DEFAULT_SORTING,
DEFAULT_PAGINATION
);
expect(result).toEqual({
page: 1,
perPage: 25,
total: 1,
results: [SOME_SLO],
});
expect(soClientMock.find).toHaveBeenCalledWith({
type: SO_SLO_TYPE,
page: 1,
perPage: 25,
filter: undefined,
search: '*availa*',
searchFields: ['name'],
sortField: 'created_at',
sortOrder: 'asc',
});
});
});
describe('indicatorTypes filter', () => {
it('includes the filter on indicator types when provided', async () => {
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
soClientMock.find.mockResolvedValueOnce(createFindResponse([SOME_SLO]));
const result = await repository.find(
{ indicatorTypes: ['sli.kql.custom'] },
DEFAULT_SORTING,
DEFAULT_PAGINATION
);
expect(result).toEqual({
page: 1,
perPage: 25,
total: 1,
results: [SOME_SLO],
});
expect(soClientMock.find).toHaveBeenCalledWith({
type: SO_SLO_TYPE,
page: 1,
perPage: 25,
filter: `(slo.attributes.indicator.type: sli.kql.custom)`,
search: undefined,
searchFields: undefined,
sortField: 'created_at',
sortOrder: 'asc',
});
});
it('includes the filter on indicator types as logical OR when provided', async () => {
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
soClientMock.find.mockResolvedValueOnce(createFindResponse([SOME_SLO]));
const result = await repository.find(
{ indicatorTypes: ['sli.kql.custom', 'sli.apm.transactionDuration'] },
DEFAULT_SORTING,
DEFAULT_PAGINATION
);
expect(result).toEqual({
page: 1,
perPage: 25,
total: 1,
results: [SOME_SLO],
});
expect(soClientMock.find).toHaveBeenCalledWith({
type: SO_SLO_TYPE,
page: 1,
perPage: 25,
filter: `(slo.attributes.indicator.type: sli.kql.custom or slo.attributes.indicator.type: sli.apm.transactionDuration)`,
search: undefined,
searchFields: undefined,
sortField: 'created_at',
sortOrder: 'asc',
});
});
});
it('includes search on name and filter on indicator types', async () => {
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
soClientMock.find.mockResolvedValueOnce(createFindResponse([SOME_SLO]));
const result = await repository.find(
{ name: 'latency', indicatorTypes: ['sli.kql.custom', 'sli.apm.transactionDuration'] },
DEFAULT_SORTING,
DEFAULT_PAGINATION
);
expect(result).toEqual({
page: 1,
perPage: 25,
total: 1,
results: [SOME_SLO],
});
expect(soClientMock.find).toHaveBeenCalledWith({
type: SO_SLO_TYPE,
page: 1,
perPage: 25,
filter: `(slo.attributes.indicator.type: sli.kql.custom or slo.attributes.indicator.type: sli.apm.transactionDuration)`,
search: '*latency*',
searchFields: ['name'],
sortField: 'created_at',
sortOrder: 'asc',
});
});
it('does not include the search or filter when no criteria provided', async () => {
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
soClientMock.find.mockResolvedValueOnce(createFindResponse([SOME_SLO]));
const result = await repository.find({}, DEFAULT_SORTING, DEFAULT_PAGINATION);
expect(result).toEqual({
page: 1,
perPage: 25,
total: 1,
results: [SOME_SLO],
});
expect(soClientMock.find).toHaveBeenCalledWith({
type: SO_SLO_TYPE,
page: 1,
perPage: 25,
search: undefined,
searchFields: undefined,
sortField: 'created_at',
sortOrder: 'asc',
});
});
it('sorts by creation time ascending', async () => {
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
soClientMock.find.mockResolvedValueOnce(createFindResponse([SOME_SLO]));
await repository.find({}, DEFAULT_SORTING, DEFAULT_PAGINATION);
expect(soClientMock.find).toHaveBeenCalledWith({
type: SO_SLO_TYPE,
page: 1,
perPage: 25,
search: undefined,
searchFields: undefined,
sortField: 'created_at',
sortOrder: 'asc',
});
});
it('sorts by creation time descending', async () => {
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
soClientMock.find.mockResolvedValueOnce(createFindResponse([SOME_SLO]));
await repository.find(
{},
{ field: SortField.CreationTime, direction: SortDirection.Desc },
DEFAULT_PAGINATION
);
expect(soClientMock.find).toHaveBeenCalledWith({
type: SO_SLO_TYPE,
page: 1,
perPage: 25,
search: undefined,
searchFields: undefined,
sortField: 'created_at',
sortOrder: 'desc',
});
});
it('sorts by indicator type', async () => {
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
soClientMock.find.mockResolvedValueOnce(createFindResponse([SOME_SLO]));
await repository.find(
{},
{ field: SortField.IndicatorType, direction: SortDirection.Asc },
DEFAULT_PAGINATION
);
expect(soClientMock.find).toHaveBeenCalledWith({
type: SO_SLO_TYPE,
page: 1,
perPage: 25,
search: undefined,
searchFields: undefined,
sortField: 'indicator.type',
sortOrder: 'asc',
});
});
});
});

View file

@ -5,62 +5,21 @@
* 2.0.
*/
import * as t from 'io-ts';
import { fold } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server';
import { sloSchema } from '@kbn/slo-schema';
import { StoredSLO, SLO } from '../../domain/models';
import { SO_SLO_TYPE } from '../../saved_objects';
import { fold } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
import * as t from 'io-ts';
import { SLO, StoredSLO } from '../../domain/models';
import { SLOIdConflict, SLONotFound } from '../../errors';
type ObjectValues<T> = T[keyof T];
export interface Criteria {
name?: string;
indicatorTypes?: string[];
}
export interface Pagination {
page: number;
perPage: number;
}
export const SortDirection = {
Asc: 'Asc',
Desc: 'Desc',
} as const;
type SortDirection = ObjectValues<typeof SortDirection>;
export const SortField = {
CreationTime: 'CreationTime',
IndicatorType: 'IndicatorType',
};
type SortField = ObjectValues<typeof SortField>;
export interface Sort {
field: SortField;
direction: SortDirection;
}
export interface Paginated<T> {
page: number;
perPage: number;
total: number;
results: T[];
}
import { SO_SLO_TYPE } from '../../saved_objects';
export interface SLORepository {
save(slo: SLO, options?: { throwOnConflict: boolean }): Promise<SLO>;
findAllByIds(ids: string[]): Promise<SLO[]>;
findById(id: string): Promise<SLO>;
deleteById(id: string): Promise<void>;
find(criteria: Criteria, sort: Sort, pagination: Pagination): Promise<Paginated<SLO>>;
}
export class KibanaSavedObjectsSLORepository implements SLORepository {
@ -120,29 +79,6 @@ export class KibanaSavedObjectsSLORepository implements SLORepository {
await this.soClient.delete(SO_SLO_TYPE, response.saved_objects[0].id);
}
async find(criteria: Criteria, sort: Sort, pagination: Pagination): Promise<Paginated<SLO>> {
const { search, searchFields } = buildSearch(criteria);
const filterKuery = buildFilterKuery(criteria);
const { sortField, sortOrder } = buildSortQuery(sort);
const response = await this.soClient.find<StoredSLO>({
type: SO_SLO_TYPE,
page: pagination.page,
perPage: pagination.perPage,
search,
searchFields,
filter: filterKuery,
sortField,
sortOrder,
});
return {
total: response.total,
page: response.page,
perPage: response.per_page,
results: response.saved_objects.map((so) => toSLO(so.attributes)),
};
}
async findAllByIds(ids: string[]): Promise<SLO[]> {
if (ids.length === 0) return [];
@ -163,47 +99,6 @@ export class KibanaSavedObjectsSLORepository implements SLORepository {
}
}
function buildSearch(criteria: Criteria): {
search: string | undefined;
searchFields: string[] | undefined;
} {
if (!criteria.name) {
return { search: undefined, searchFields: undefined };
}
return { search: addWildcardsIfAbsent(criteria.name), searchFields: ['name'] };
}
function buildFilterKuery(criteria: Criteria): string | undefined {
const filters: string[] = [];
if (!!criteria.indicatorTypes) {
const indicatorTypesFilter: string[] = criteria.indicatorTypes.map(
(indicatorType) => `slo.attributes.indicator.type: ${indicatorType}`
);
filters.push(`(${indicatorTypesFilter.join(' or ')})`);
}
return filters.length > 0 ? filters.join(' and ') : undefined;
}
function buildSortQuery(sort: Sort): { sortField: string; sortOrder: 'asc' | 'desc' } {
let sortField: string;
switch (sort.field) {
case SortField.IndicatorType:
sortField = 'indicator.type';
break;
case SortField.CreationTime:
default:
sortField = 'created_at';
break;
}
return {
sortField,
sortOrder: sort.direction === SortDirection.Desc ? 'desc' : 'asc',
};
}
function toStoredSLO(slo: SLO): StoredSLO {
return sloSchema.encode(slo);
}
@ -216,17 +111,3 @@ function toSLO(storedSLO: StoredSLO): SLO {
}, t.identity)
);
}
const WILDCARD_CHAR = '*';
function addWildcardsIfAbsent(value: string): string {
let updatedValue = value;
if (updatedValue.substring(0, 1) !== WILDCARD_CHAR) {
updatedValue = `${WILDCARD_CHAR}${updatedValue}`;
}
if (value.substring(value.length - 1) !== WILDCARD_CHAR) {
updatedValue = `${updatedValue}${WILDCARD_CHAR}`;
}
return updatedValue;
}

View file

@ -0,0 +1,116 @@
/*
* 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 { loggerMock } from '@kbn/logging-mocks';
import {
aHitFromSummaryIndex,
aHitFromTempSummaryIndex,
aSummaryDocument,
} from './fixtures/summary_search_document';
import {
DefaultSummarySearchClient,
Pagination,
Sort,
SummarySearchClient,
} from './summary_search_client';
const defaultSort: Sort = {
field: 'sli_value',
direction: 'asc',
};
const defaultPagination: Pagination = {
page: 1,
perPage: 20,
};
describe('Summary Search Client', () => {
let esClientMock: ElasticsearchClientMock;
let service: SummarySearchClient;
beforeEach(() => {
esClientMock = elasticsearchServiceMock.createElasticsearchClient();
service = new DefaultSummarySearchClient(esClientMock, loggerMock.create());
});
it('returns an empty response on error', async () => {
esClientMock.count.mockRejectedValue(new Error('Cannot reach es'));
await expect(service.search('', defaultSort, defaultPagination)).resolves
.toMatchInlineSnapshot(`
Object {
"page": 1,
"perPage": 20,
"results": Array [],
"total": 0,
}
`);
});
it('returns an empty response when the kql filter returns no document count', async () => {
esClientMock.count.mockResolvedValue({
count: 0,
_shards: { failed: 0, successful: 1, total: 1 },
});
await expect(service.search('', defaultSort, defaultPagination)).resolves
.toMatchInlineSnapshot(`
Object {
"page": 1,
"perPage": 20,
"results": Array [],
"total": 0,
}
`);
});
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';
esClientMock.count.mockResolvedValue({
count: 8,
_shards: { failed: 0, successful: 1, total: 1 },
});
esClientMock.search.mockResolvedValue({
took: 0,
timed_out: false,
_shards: {
total: 2,
successful: 2,
skipped: 0,
failed: 0,
},
hits: {
total: {
value: 6,
relation: 'eq',
},
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
],
},
});
const results = await service.search('', defaultSort, defaultPagination);
expect(esClientMock.deleteByQuery).toHaveBeenCalled();
expect(esClientMock.deleteByQuery.mock.calls[0]).toMatchSnapshot();
expect(results).toMatchSnapshot();
expect(results.total).toBe(5);
});
});

View file

@ -0,0 +1,157 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ElasticsearchClient, Logger } from '@kbn/core/server';
import { assertNever } from '@kbn/std';
import _ from 'lodash';
import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from '../../assets/constants';
import { SLOId, Status, Summary } from '../../domain/models';
import { toHighPrecision } from '../../utils/number';
import { getElastichsearchQueryOrThrow } from './transform_generators';
interface EsSummaryDocument {
slo: {
id: string;
revision: number;
};
sliValue: number;
errorBudgetConsumed: number;
errorBudgetRemaining: number;
errorBudgetInitial: number;
errorBudgetEstimated: boolean;
statusCode: number;
status: Status;
isTempDoc: boolean;
}
export interface Paginated<T> {
total: number;
page: number;
perPage: number;
results: T[];
}
export interface SLOSummary {
id: SLOId;
summary: Summary;
}
export type SortField = 'error_budget_consumed' | 'error_budget_remaining' | 'sli_value' | 'status';
export interface Sort {
field: SortField;
direction: 'asc' | 'desc';
}
export interface Pagination {
page: number;
perPage: number;
}
export interface SummarySearchClient {
search(kqlQuery: string, sort: Sort, pagination: Pagination): Promise<Paginated<SLOSummary>>;
}
export class DefaultSummarySearchClient implements SummarySearchClient {
constructor(private esClient: ElasticsearchClient, private logger: Logger) {}
async search(
kqlQuery: string,
sort: Sort,
pagination: Pagination
): Promise<Paginated<SLOSummary>> {
try {
const { count: total } = await this.esClient.count({
index: SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
query: getElastichsearchQueryOrThrow(kqlQuery),
});
if (total === 0) {
return { total: 0, perPage: pagination.perPage, page: pagination.page, results: [] };
}
const summarySearch = await this.esClient.search<EsSummaryDocument>({
index: SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
query: getElastichsearchQueryOrThrow(kqlQuery),
sort: {
// non-temp first, then temp documents
isTempDoc: {
order: 'asc',
},
[toDocumentSortField(sort.field)]: {
order: sort.direction,
},
},
from: (pagination.page - 1) * pagination.perPage,
size: pagination.perPage * 2, // twice as much as we return, in case they are all duplicate temp/non-temp summary
});
const [tempSummaryDocuments, summaryDocuments] = _.partition(
summarySearch.hits.hits,
(doc) => !!doc._source?.isTempDoc
);
// Always attempt to delete temporary summary documents with an existing non-temp summary document
// The temp summary documents are _eventually_ removed as we get through the real summary documents
const summarySloIds = summaryDocuments.map((doc) => doc._source?.slo.id);
await this.esClient.deleteByQuery({
index: SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
wait_for_completion: false,
query: {
bool: {
filter: [{ terms: { 'slo.id': summarySloIds } }, { term: { isTempDoc: true } }],
},
},
});
const tempSummaryDocumentsDeduped = tempSummaryDocuments.filter(
(doc) => !summarySloIds.includes(doc._source?.slo.id)
);
const finalResults = summaryDocuments
.concat(tempSummaryDocumentsDeduped)
.slice(0, pagination.perPage);
const finalTotal = total - (tempSummaryDocuments.length - tempSummaryDocumentsDeduped.length);
return {
total: finalTotal,
perPage: pagination.perPage,
page: pagination.page,
results: finalResults.map((doc) => ({
id: doc._source!.slo.id,
summary: {
errorBudget: {
initial: toHighPrecision(doc._source!.errorBudgetInitial),
consumed: toHighPrecision(doc._source!.errorBudgetConsumed),
remaining: toHighPrecision(doc._source!.errorBudgetRemaining),
isEstimated: doc._source!.errorBudgetEstimated,
},
sliValue: toHighPrecision(doc._source!.sliValue),
status: doc._source!.status,
},
})),
};
} catch (err) {
this.logger.error(new Error('Summary search query error', { cause: err }));
return { total: 0, perPage: pagination.perPage, page: pagination.page, results: [] };
}
}
}
function toDocumentSortField(field: SortField) {
switch (field) {
case 'error_budget_consumed':
return 'errorBudgetConsumed';
case 'error_budget_remaining':
return 'errorBudgetRemaining';
case 'status':
return 'status';
case 'sli_value':
return 'sliValue';
default:
assertNever(field);
}
}

View file

@ -0,0 +1,47 @@
/*
* 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 { SLO } from '../../../../domain/models';
export function createTempSummaryDocument(slo: SLO) {
return {
service: {
environment: null,
name: null,
},
transaction: {
name: null,
type: null,
},
slo: {
indicator: {
type: slo.indicator.type,
},
timeWindow: {
duration: slo.timeWindow.duration.format(),
type: slo.timeWindow.type,
},
instanceId: '*',
name: slo.name,
description: slo.description,
id: slo.id,
budgetingMethod: slo.budgetingMethod,
revision: slo.revision,
tags: slo.tags,
},
goodEvents: 0,
totalEvents: 0,
errorBudgetEstimated: false,
errorBudgetRemaining: 1,
errorBudgetConsumed: 0,
errorBudgetInitial: 1 - slo.objective.target,
sliValue: -1,
statusCode: 0,
status: 'NO_DATA',
isTempDoc: true,
};
}

View file

@ -0,0 +1,103 @@
/*
* 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,
loggingSystemMock,
} from '@kbn/core/server/mocks';
import { MockedLogger } from '@kbn/logging-mocks';
import { DefaultSummaryTransformInstaller } from './summary_transform_installer';
import { ALL_TRANSFORM_TEMPLATES } from './templates';
describe('Summary Transform Installer', () => {
let esClientMock: ElasticsearchClientMock;
let loggerMock: jest.Mocked<MockedLogger>;
beforeEach(() => {
esClientMock = elasticsearchServiceMock.createElasticsearchClient();
loggerMock = loggingSystemMock.createLogger();
});
it('skips the installation when latest version already installed', async () => {
esClientMock.transform.getTransform.mockResolvedValue({
count: ALL_TRANSFORM_TEMPLATES.length,
// @ts-ignore
transforms: ALL_TRANSFORM_TEMPLATES.map((transform) => ({
id: transform.transform_id,
_meta: transform._meta,
})),
});
const installer = new DefaultSummaryTransformInstaller(esClientMock, loggerMock);
await installer.installAndStart();
expect(esClientMock.transform.stopTransform).not.toHaveBeenCalled();
expect(esClientMock.transform.deleteTransform).not.toHaveBeenCalled();
expect(esClientMock.transform.putTransform).not.toHaveBeenCalled();
expect(esClientMock.transform.startTransform).not.toHaveBeenCalled();
});
it('installs every summary transforms when none are already installed', async () => {
esClientMock.transform.getTransform.mockResolvedValue({ count: 0, transforms: [] });
const installer = new DefaultSummaryTransformInstaller(esClientMock, loggerMock);
await installer.installAndStart();
const nbOfTransforms = ALL_TRANSFORM_TEMPLATES.length;
expect(esClientMock.transform.stopTransform).not.toHaveBeenCalled();
expect(esClientMock.transform.deleteTransform).not.toHaveBeenCalled();
expect(esClientMock.transform.putTransform).toHaveBeenCalledTimes(nbOfTransforms);
expect(esClientMock.transform.startTransform).toHaveBeenCalledTimes(nbOfTransforms);
});
it('desinstalls previous summary transforms prior to installing the new ones', async () => {
esClientMock.transform.getTransform.mockResolvedValue({
count: ALL_TRANSFORM_TEMPLATES.length,
// @ts-ignore
transforms: ALL_TRANSFORM_TEMPLATES.map((transform) => ({
id: transform.transform_id,
_meta: { ...transform._meta, version: -1 },
})),
});
const installer = new DefaultSummaryTransformInstaller(esClientMock, loggerMock);
await installer.installAndStart();
const nbOfTransforms = ALL_TRANSFORM_TEMPLATES.length;
expect(esClientMock.transform.stopTransform).toHaveBeenCalledTimes(nbOfTransforms);
expect(esClientMock.transform.deleteTransform).toHaveBeenCalledTimes(nbOfTransforms);
expect(esClientMock.transform.putTransform).toHaveBeenCalledTimes(nbOfTransforms);
expect(esClientMock.transform.startTransform).toHaveBeenCalledTimes(nbOfTransforms);
});
it('installs only the missing summary transforms', async () => {
const occurrencesSummaryTransforms = ALL_TRANSFORM_TEMPLATES.filter((transform) =>
transform.transform_id.includes('-occurrences-')
);
esClientMock.transform.getTransform.mockResolvedValue({
count: occurrencesSummaryTransforms.length,
// @ts-ignore
transforms: occurrencesSummaryTransforms.map((transform) => ({
id: transform.transform_id,
_meta: transform._meta,
})),
});
const installer = new DefaultSummaryTransformInstaller(esClientMock, loggerMock);
await installer.installAndStart();
const nbOfTransforms = ALL_TRANSFORM_TEMPLATES.length - occurrencesSummaryTransforms.length;
expect(esClientMock.transform.stopTransform).not.toHaveBeenCalled();
expect(esClientMock.transform.deleteTransform).not.toHaveBeenCalled();
expect(esClientMock.transform.putTransform).toHaveBeenCalledTimes(nbOfTransforms);
expect(esClientMock.transform.startTransform).toHaveBeenCalledTimes(nbOfTransforms);
expect(esClientMock.transform.putTransform.mock.calls).toMatchSnapshot();
});
});

View file

@ -0,0 +1,105 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types';
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
import {
SLO_RESOURCES_VERSION,
SLO_SUMMARY_TRANSFORM_NAME_PREFIX,
} from '../../../assets/constants';
import { retryTransientEsErrors } from '../../../utils/retry';
import { ALL_TRANSFORM_TEMPLATES } from './templates';
export interface SummaryTransformInstaller {
installAndStart(): Promise<void>;
}
export class DefaultSummaryTransformInstaller implements SummaryTransformInstaller {
constructor(private esClient: ElasticsearchClient, private logger: Logger) {}
public async installAndStart(): Promise<void> {
const allTransformIds = ALL_TRANSFORM_TEMPLATES.map((transform) => transform.transform_id);
const summaryTransforms = await this.execute(() =>
this.esClient.transform.getTransform(
{ transform_id: `${SLO_SUMMARY_TRANSFORM_NAME_PREFIX}*`, allow_no_match: true },
{ ignore: [404] }
)
);
const alreadyInstalled =
summaryTransforms.count === allTransformIds.length &&
summaryTransforms.transforms.every(
(transform) => transform._meta?.version === SLO_RESOURCES_VERSION
) &&
summaryTransforms.transforms.every((transform) => allTransformIds.includes(transform.id));
if (alreadyInstalled) {
this.logger.info(`SLO summary transforms already installed - skipping`);
return;
}
for (const transformTemplate of ALL_TRANSFORM_TEMPLATES) {
const transformId = transformTemplate.transform_id;
const transform = summaryTransforms.transforms.find((t) => t.id === transformId);
const transformAlreadyInstalled =
!!transform && transform._meta?.version === SLO_RESOURCES_VERSION;
const previousTransformAlreadyInstalled =
!!transform && transform._meta?.version !== SLO_RESOURCES_VERSION;
if (transformAlreadyInstalled) {
this.logger.info(`SLO summary transform [${transformId}] already installed - skipping`);
continue;
}
if (previousTransformAlreadyInstalled) {
await this.deletePreviousTransformVersion(transformId);
}
await this.installTransform(transformId, transformTemplate);
await this.startTransform(transformId);
}
this.logger.info(`SLO summary transforms installed and started`);
}
private async installTransform(
transformId: string,
transformTemplate: TransformPutTransformRequest
) {
this.logger.info(`Installing SLO summary transform [${transformId}]`);
await this.execute(() =>
this.esClient.transform.putTransform(transformTemplate, { ignore: [409] })
);
}
private async deletePreviousTransformVersion(transformId: string) {
this.logger.info(`Deleting previous SLO summary transform [${transformId}]`);
await this.execute(() =>
this.esClient.transform.stopTransform(
{ transform_id: transformId, allow_no_match: true, force: true },
{ ignore: [409, 404] }
)
);
await this.execute(() =>
this.esClient.transform.deleteTransform(
{ transform_id: transformId, force: true },
{ ignore: [409, 404] }
)
);
}
private async startTransform(transformId: string) {
this.logger.info(`Starting SLO summary transform [${transformId}]`);
await this.execute(() =>
this.esClient.transform.startTransform({ transform_id: transformId }, { ignore: [409] })
);
}
private async execute<T>(esCall: () => Promise<T>): Promise<T> {
return await retryTransientEsErrors(esCall, { logger: this.logger });
}
}

View file

@ -0,0 +1,95 @@
/*
* 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.
*/
export const groupBy = {
'slo.id': {
terms: {
field: 'slo.id',
},
},
'slo.revision': {
terms: {
field: 'slo.revision',
},
},
'slo.instanceId': {
terms: {
field: 'slo.instanceId',
},
},
'slo.name': {
terms: {
field: 'slo.name',
},
},
'slo.description': {
terms: {
field: 'slo.description',
},
},
'slo.tags': {
terms: {
field: 'slo.tags',
},
},
'slo.indicator.type': {
terms: {
field: 'slo.indicator.type',
},
},
'slo.budgetingMethod': {
terms: {
field: 'slo.budgetingMethod',
},
},
'slo.timeWindow.duration': {
terms: {
field: 'slo.timeWindow.duration',
},
},
'slo.timeWindow.type': {
terms: {
field: 'slo.timeWindow.type',
},
},
errorBudgetEstimated: {
terms: {
field: 'errorBudgetEstimated',
},
},
// Differentiate the temporary document from the summary one
isTempDoc: {
terms: {
field: 'isTempDoc',
},
},
// optional fields: only specified for APM indicators. Must include missing_bucket:true
'service.name': {
terms: {
field: 'service.name',
missing_bucket: true,
},
},
'service.environment': {
terms: {
field: 'service.environment',
missing_bucket: true,
},
},
'transaction.name': {
terms: {
field: 'transaction.name',
missing_bucket: true,
},
},
'transaction.type': {
terms: {
field: 'transaction.type',
missing_bucket: true,
},
},
};

View file

@ -0,0 +1,30 @@
/*
* 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 { SUMMARY_OCCURRENCES_7D_ROLLING } from './summary_occurrences_7d_rolling';
import { SUMMARY_OCCURRENCES_30D_ROLLING } from './summary_occurrences_30d_rolling';
import { SUMMARY_OCCURRENCES_90D_ROLLING } from './summary_occurrences_90d_rolling';
import { SUMMARY_TIMESLICES_7D_ROLLING } from './summary_timeslices_7d_rolling';
import { SUMMARY_TIMESLICES_30D_ROLLING } from './summary_timeslices_30d_rolling';
import { SUMMARY_TIMESLICES_90D_ROLLING } from './summary_timeslices_90d_rolling';
import { SUMMARY_OCCURRENCES_WEEKLY_ALIGNED } from './summary_occurrences_weekly_aligned';
import { SUMMARY_OCCURRENCES_MONTHLY_ALIGNED } from './summary_occurrences_monthly_aligned';
import { SUMMARY_TIMESLICES_WEEKLY_ALIGNED } from './summary_timeslices_weekly_aligned';
import { SUMMARY_TIMESLICES_MONTHLY_ALIGNED } from './summary_timeslices_monthly_aligned';
export const ALL_TRANSFORM_TEMPLATES = [
SUMMARY_OCCURRENCES_7D_ROLLING,
SUMMARY_OCCURRENCES_30D_ROLLING,
SUMMARY_OCCURRENCES_90D_ROLLING,
SUMMARY_OCCURRENCES_WEEKLY_ALIGNED,
SUMMARY_OCCURRENCES_MONTHLY_ALIGNED,
SUMMARY_TIMESLICES_7D_ROLLING,
SUMMARY_TIMESLICES_30D_ROLLING,
SUMMARY_TIMESLICES_90D_ROLLING,
SUMMARY_TIMESLICES_WEEKLY_ALIGNED,
SUMMARY_TIMESLICES_MONTHLY_ALIGNED,
];

View file

@ -0,0 +1,153 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types';
import {
SLO_DESTINATION_INDEX_PATTERN,
SLO_RESOURCES_VERSION,
SLO_SUMMARY_DESTINATION_INDEX_NAME,
SLO_SUMMARY_INGEST_PIPELINE_NAME,
SLO_SUMMARY_TRANSFORM_NAME_PREFIX,
} from '../../../../assets/constants';
import { groupBy } from './common';
export const SUMMARY_OCCURRENCES_30D_ROLLING: TransformPutTransformRequest = {
transform_id: `${SLO_SUMMARY_TRANSFORM_NAME_PREFIX}occurrences-30d-rolling`,
dest: {
index: SLO_SUMMARY_DESTINATION_INDEX_NAME,
pipeline: SLO_SUMMARY_INGEST_PIPELINE_NAME,
},
source: {
index: SLO_DESTINATION_INDEX_PATTERN,
runtime_mappings: {
errorBudgetEstimated: {
type: 'boolean',
script: 'emit(false)',
},
isTempDoc: {
type: 'boolean',
script: 'emit(false)',
},
},
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: 'now-30d/m',
lte: 'now/m',
},
},
},
{
term: {
'slo.budgetingMethod': 'occurrences',
},
},
{
term: {
'slo.timeWindow.type': 'rolling',
},
},
{
term: {
'slo.timeWindow.duration': '30d',
},
},
],
},
},
},
pivot: {
group_by: groupBy,
aggregations: {
goodEvents: {
sum: {
field: 'slo.numerator',
},
},
totalEvents: {
sum: {
field: 'slo.denominator',
},
},
_objectiveTarget: {
max: {
field: 'slo.objective.target',
},
},
sliValue: {
bucket_script: {
buckets_path: {
goodEvents: 'goodEvents',
totalEvents: 'totalEvents',
},
script:
'if (params.totalEvents == 0) { return -1 } else { return params.goodEvents / params.totalEvents }',
},
},
errorBudgetInitial: {
bucket_script: {
buckets_path: {
objectiveTarget: '_objectiveTarget',
},
script: '1 - params.objectiveTarget',
},
},
errorBudgetConsumed: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
errorBudgetInitial: 'errorBudgetInitial',
},
script:
'if (params.sliValue == -1) { return 0 } else { return (1 - params.sliValue) / params.errorBudgetInitial }',
},
},
errorBudgetRemaining: {
bucket_script: {
buckets_path: {
errorBudgetConsummed: 'errorBudgetConsumed',
},
script: '1 - params.errorBudgetConsummed',
},
},
statusCode: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
objectiveTarget: '_objectiveTarget',
errorBudgetRemaining: 'errorBudgetRemaining',
},
script: {
source:
'if (params.sliValue == -1) { return 0 } else if (params.sliValue >= params.objectiveTarget) { return 4 } else if (params.errorBudgetRemaining > 0) { return 2 } else { return 1 }',
},
},
},
},
},
description:
'Summarize every SLO with occurrences budgeting method and a 30 days rolling time window',
frequency: '1m',
sync: {
time: {
field: '@timestamp',
delay: '125s',
},
},
settings: {
deduce_mappings: false,
max_page_search_size: 8000,
},
_meta: {
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},
};

View file

@ -0,0 +1,152 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types';
import {
SLO_DESTINATION_INDEX_PATTERN,
SLO_RESOURCES_VERSION,
SLO_SUMMARY_DESTINATION_INDEX_NAME,
SLO_SUMMARY_INGEST_PIPELINE_NAME,
SLO_SUMMARY_TRANSFORM_NAME_PREFIX,
} from '../../../../assets/constants';
import { groupBy } from './common';
export const SUMMARY_OCCURRENCES_7D_ROLLING: TransformPutTransformRequest = {
transform_id: `${SLO_SUMMARY_TRANSFORM_NAME_PREFIX}occurrences-7d-rolling`,
dest: {
pipeline: SLO_SUMMARY_INGEST_PIPELINE_NAME,
index: SLO_SUMMARY_DESTINATION_INDEX_NAME,
},
source: {
index: SLO_DESTINATION_INDEX_PATTERN,
runtime_mappings: {
errorBudgetEstimated: {
type: 'boolean',
script: 'emit(false)',
},
isTempDoc: {
type: 'boolean',
script: 'emit(false)',
},
},
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: 'now-7d/m',
lte: 'now/m',
},
},
},
{
term: {
'slo.budgetingMethod': 'occurrences',
},
},
{
term: {
'slo.timeWindow.type': 'rolling',
},
},
{
term: {
'slo.timeWindow.duration': '7d',
},
},
],
},
},
},
pivot: {
group_by: groupBy,
aggregations: {
goodEvents: {
sum: {
field: 'slo.numerator',
},
},
totalEvents: {
sum: {
field: 'slo.denominator',
},
},
_objectiveTarget: {
max: {
field: 'slo.objective.target',
},
},
sliValue: {
bucket_script: {
buckets_path: {
goodEvents: 'goodEvents',
totalEvents: 'totalEvents',
},
script:
'if (params.totalEvents == 0) { return -1 } else { return params.goodEvents / params.totalEvents }',
},
},
errorBudgetInitial: {
bucket_script: {
buckets_path: {
objectiveTarget: '_objectiveTarget',
},
script: '1 - params.objectiveTarget',
},
},
errorBudgetConsumed: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
errorBudgetInitial: 'errorBudgetInitial',
},
script:
'if (params.sliValue == -1) { return 0 } else { return (1 - params.sliValue) / params.errorBudgetInitial }',
},
},
errorBudgetRemaining: {
bucket_script: {
buckets_path: {
errorBudgetConsummed: 'errorBudgetConsumed',
},
script: '1 - params.errorBudgetConsummed',
},
},
statusCode: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
objectiveTarget: '_objectiveTarget',
errorBudgetRemaining: 'errorBudgetRemaining',
},
script: {
source:
'if (params.sliValue == -1) { return 0 } else if (params.sliValue >= params.objectiveTarget) { return 4 } else if (params.errorBudgetRemaining > 0) { return 2 } else { return 1 }',
},
},
},
},
},
description:
'Summarize every SLO with occurrences budgeting method and a 7 days rolling time window',
frequency: '1m',
sync: {
time: {
field: '@timestamp',
delay: '125s',
},
},
settings: {
deduce_mappings: false,
},
_meta: {
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},
};

View file

@ -0,0 +1,152 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types';
import {
SLO_DESTINATION_INDEX_PATTERN,
SLO_RESOURCES_VERSION,
SLO_SUMMARY_DESTINATION_INDEX_NAME,
SLO_SUMMARY_INGEST_PIPELINE_NAME,
SLO_SUMMARY_TRANSFORM_NAME_PREFIX,
} from '../../../../assets/constants';
import { groupBy } from './common';
export const SUMMARY_OCCURRENCES_90D_ROLLING: TransformPutTransformRequest = {
transform_id: `${SLO_SUMMARY_TRANSFORM_NAME_PREFIX}occurrences-90d-rolling`,
dest: {
index: SLO_SUMMARY_DESTINATION_INDEX_NAME,
pipeline: SLO_SUMMARY_INGEST_PIPELINE_NAME,
},
source: {
index: SLO_DESTINATION_INDEX_PATTERN,
runtime_mappings: {
errorBudgetEstimated: {
type: 'boolean',
script: 'emit(false)',
},
isTempDoc: {
type: 'boolean',
script: 'emit(false)',
},
},
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: 'now-90d/m',
lte: 'now/m',
},
},
},
{
term: {
'slo.budgetingMethod': 'occurrences',
},
},
{
term: {
'slo.timeWindow.type': 'rolling',
},
},
{
term: {
'slo.timeWindow.duration': '90d',
},
},
],
},
},
},
pivot: {
group_by: groupBy,
aggregations: {
goodEvents: {
sum: {
field: 'slo.numerator',
},
},
totalEvents: {
sum: {
field: 'slo.denominator',
},
},
_objectiveTarget: {
max: {
field: 'slo.objective.target',
},
},
sliValue: {
bucket_script: {
buckets_path: {
goodEvents: 'goodEvents',
totalEvents: 'totalEvents',
},
script:
'if (params.totalEvents == 0) { return -1 } else { return params.goodEvents / params.totalEvents }',
},
},
errorBudgetInitial: {
bucket_script: {
buckets_path: {
objectiveTarget: '_objectiveTarget',
},
script: '1 - params.objectiveTarget',
},
},
errorBudgetConsumed: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
errorBudgetInitial: 'errorBudgetInitial',
},
script:
'if (params.sliValue == -1) { return 0 } else { return (1 - params.sliValue) / params.errorBudgetInitial }',
},
},
errorBudgetRemaining: {
bucket_script: {
buckets_path: {
errorBudgetConsummed: 'errorBudgetConsumed',
},
script: '1 - params.errorBudgetConsummed',
},
},
statusCode: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
objectiveTarget: '_objectiveTarget',
errorBudgetRemaining: 'errorBudgetRemaining',
},
script: {
source:
'if (params.sliValue == -1) { return 0 } else if (params.sliValue >= params.objectiveTarget) { return 4 } else if (params.errorBudgetRemaining > 0) { return 2 } else { return 1 }',
},
},
},
},
},
description:
'Summarize every SLO with occurrences budgeting method and a 90 days rolling time window',
frequency: '1m',
sync: {
time: {
field: '@timestamp',
delay: '125s',
},
},
settings: {
deduce_mappings: false,
},
_meta: {
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},
};

View file

@ -0,0 +1,150 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types';
import {
SLO_DESTINATION_INDEX_PATTERN,
SLO_RESOURCES_VERSION,
SLO_SUMMARY_DESTINATION_INDEX_NAME,
SLO_SUMMARY_INGEST_PIPELINE_NAME,
SLO_SUMMARY_TRANSFORM_NAME_PREFIX,
} from '../../../../assets/constants';
import { groupBy } from './common';
export const SUMMARY_OCCURRENCES_MONTHLY_ALIGNED: TransformPutTransformRequest = {
transform_id: `${SLO_SUMMARY_TRANSFORM_NAME_PREFIX}occurrences-monthly-aligned`,
dest: {
index: SLO_SUMMARY_DESTINATION_INDEX_NAME,
pipeline: SLO_SUMMARY_INGEST_PIPELINE_NAME,
},
source: {
index: SLO_DESTINATION_INDEX_PATTERN,
runtime_mappings: {
errorBudgetEstimated: {
type: 'boolean',
script: 'emit(true)',
},
isTempDoc: {
type: 'boolean',
script: 'emit(false)',
},
},
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: 'now/M',
lte: 'now/m',
},
},
},
{
term: {
'slo.budgetingMethod': 'occurrences',
},
},
{
term: {
'slo.timeWindow.type': 'calendarAligned',
},
},
{
term: {
'slo.timeWindow.duration': '1M',
},
},
],
},
},
},
pivot: {
group_by: groupBy,
aggregations: {
_objectiveTarget: {
max: {
field: 'slo.objective.target',
},
},
goodEvents: {
sum: {
field: 'slo.numerator',
},
},
totalEvents: {
sum: {
field: 'slo.denominator',
},
},
sliValue: {
bucket_script: {
buckets_path: {
goodEvents: 'goodEvents',
totalEvents: 'totalEvents',
},
script:
'if (params.totalEvents == 0) { return -1 } else { return params.goodEvents / params.totalEvents }',
},
},
errorBudgetInitial: {
bucket_script: {
buckets_path: {
objective: '_objectiveTarget',
},
script: '1 - params.objective',
},
},
errorBudgetConsumed: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
errorBudgetInitial: 'errorBudgetInitial',
},
script:
'if (params.sliValue == -1) { return 0 } else { return (1 - params.sliValue) / params.errorBudgetInitial }',
},
},
errorBudgetRemaining: {
bucket_script: {
buckets_path: {
errorBudgetConsumed: 'errorBudgetConsumed',
},
script: '1 - params.errorBudgetConsumed',
},
},
statusCode: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
objective: '_objectiveTarget',
errorBudgetRemaining: 'errorBudgetRemaining',
},
script:
'if (params.sliValue == -1) { return 0 } else if (params.sliValue >= params.objective) { return 4 } else if (params.errorBudgetRemaining > 0) { return 2 } else { return 1 }',
},
},
},
},
description:
'Summarize every SLO with occurrences budgeting method and a monthly calendar aligned time window',
frequency: '1m',
sync: {
time: {
field: '@timestamp',
delay: '125s',
},
},
settings: {
deduce_mappings: false,
},
_meta: {
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},
};

View file

@ -0,0 +1,150 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types';
import {
SLO_DESTINATION_INDEX_PATTERN,
SLO_RESOURCES_VERSION,
SLO_SUMMARY_DESTINATION_INDEX_NAME,
SLO_SUMMARY_INGEST_PIPELINE_NAME,
SLO_SUMMARY_TRANSFORM_NAME_PREFIX,
} from '../../../../assets/constants';
import { groupBy } from './common';
export const SUMMARY_OCCURRENCES_WEEKLY_ALIGNED: TransformPutTransformRequest = {
transform_id: `${SLO_SUMMARY_TRANSFORM_NAME_PREFIX}occurrences-weekly-aligned`,
dest: {
index: SLO_SUMMARY_DESTINATION_INDEX_NAME,
pipeline: SLO_SUMMARY_INGEST_PIPELINE_NAME,
},
source: {
index: SLO_DESTINATION_INDEX_PATTERN,
runtime_mappings: {
errorBudgetEstimated: {
type: 'boolean',
script: 'emit(true)',
},
isTempDoc: {
type: 'boolean',
script: 'emit(false)',
},
},
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: 'now/w',
lte: 'now/m',
},
},
},
{
term: {
'slo.budgetingMethod': 'occurrences',
},
},
{
term: {
'slo.timeWindow.type': 'calendarAligned',
},
},
{
term: {
'slo.timeWindow.duration': '1w',
},
},
],
},
},
},
pivot: {
group_by: groupBy,
aggregations: {
_objectiveTarget: {
max: {
field: 'slo.objective.target',
},
},
goodEvents: {
sum: {
field: 'slo.numerator',
},
},
totalEvents: {
sum: {
field: 'slo.denominator',
},
},
sliValue: {
bucket_script: {
buckets_path: {
goodEvents: 'goodEvents',
totalEvents: 'totalEvents',
},
script:
'if (params.totalEvents == 0) { return -1 } else { return params.goodEvents / params.totalEvents }',
},
},
errorBudgetInitial: {
bucket_script: {
buckets_path: {
objective: '_objectiveTarget',
},
script: '1 - params.objective',
},
},
errorBudgetConsumed: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
errorBudgetInitial: 'errorBudgetInitial',
},
script:
'if (params.sliValue == -1) { return 0 } else { return (1 - params.sliValue) / params.errorBudgetInitial }',
},
},
errorBudgetRemaining: {
bucket_script: {
buckets_path: {
errorBudgetConsumed: 'errorBudgetConsumed',
},
script: '1 - params.errorBudgetConsumed',
},
},
statusCode: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
objective: '_objectiveTarget',
errorBudgetRemaining: 'errorBudgetRemaining',
},
script:
'if (params.sliValue == -1) { return 0 } else if (params.sliValue >= params.objective) { return 4 } else if (params.errorBudgetRemaining > 0) { return 2 } else { return 1 }',
},
},
},
},
description:
'Summarize every SLO with occurrences budgeting method and a weekly calendar aligned time window',
frequency: '1m',
sync: {
time: {
field: '@timestamp',
delay: '125s',
},
},
settings: {
deduce_mappings: false,
},
_meta: {
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},
};

View file

@ -0,0 +1,152 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types';
import {
SLO_DESTINATION_INDEX_PATTERN,
SLO_RESOURCES_VERSION,
SLO_SUMMARY_DESTINATION_INDEX_NAME,
SLO_SUMMARY_INGEST_PIPELINE_NAME,
SLO_SUMMARY_TRANSFORM_NAME_PREFIX,
} from '../../../../assets/constants';
import { groupBy } from './common';
export const SUMMARY_TIMESLICES_30D_ROLLING: TransformPutTransformRequest = {
transform_id: `${SLO_SUMMARY_TRANSFORM_NAME_PREFIX}timeslices-30d-rolling`,
dest: {
index: SLO_SUMMARY_DESTINATION_INDEX_NAME,
pipeline: SLO_SUMMARY_INGEST_PIPELINE_NAME,
},
source: {
index: SLO_DESTINATION_INDEX_PATTERN,
runtime_mappings: {
errorBudgetEstimated: {
type: 'boolean',
script: 'emit(false)',
},
isTempDoc: {
type: 'boolean',
script: 'emit(false)',
},
},
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: 'now-30d/m',
lte: 'now/m',
},
},
},
{
term: {
'slo.budgetingMethod': 'timeslices',
},
},
{
term: {
'slo.timeWindow.type': 'rolling',
},
},
{
term: {
'slo.timeWindow.duration': '30d',
},
},
],
},
},
},
pivot: {
group_by: groupBy,
aggregations: {
goodEvents: {
sum: {
field: 'slo.isGoodSlice',
},
},
totalEvents: {
value_count: {
field: 'slo.isGoodSlice',
},
},
_objectiveTarget: {
max: {
field: 'slo.objective.target',
},
},
sliValue: {
bucket_script: {
buckets_path: {
goodEvents: 'goodEvents',
totalEvents: 'totalEvents',
},
script:
'if (params.totalEvents == 0) { return -1 } else { return params.goodEvents / params.totalEvents }',
},
},
errorBudgetInitial: {
bucket_script: {
buckets_path: {
objectiveTarget: '_objectiveTarget',
},
script: '1 - params.objectiveTarget',
},
},
errorBudgetConsumed: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
errorBudgetInitial: 'errorBudgetInitial',
},
script:
'if (params.sliValue == -1) { return 0 } else { return (1 - params.sliValue) / params.errorBudgetInitial }',
},
},
errorBudgetRemaining: {
bucket_script: {
buckets_path: {
errorBudgetConsummed: 'errorBudgetConsumed',
},
script: '1 - params.errorBudgetConsummed',
},
},
statusCode: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
objectiveTarget: '_objectiveTarget',
errorBudgetRemaining: 'errorBudgetRemaining',
},
script: {
source:
'if (params.sliValue == -1) { return 0 } else if (params.sliValue >= params.objectiveTarget) { return 4 } else if (params.errorBudgetRemaining > 0) { return 2 } else { return 1 }',
},
},
},
},
},
description:
'Summarize every SLO with timeslices budgeting method and a 30 days rolling time window',
frequency: '1m',
sync: {
time: {
field: '@timestamp',
delay: '125s',
},
},
settings: {
deduce_mappings: false,
},
_meta: {
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},
};

View file

@ -0,0 +1,152 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types';
import {
SLO_DESTINATION_INDEX_PATTERN,
SLO_RESOURCES_VERSION,
SLO_SUMMARY_DESTINATION_INDEX_NAME,
SLO_SUMMARY_INGEST_PIPELINE_NAME,
SLO_SUMMARY_TRANSFORM_NAME_PREFIX,
} from '../../../../assets/constants';
import { groupBy } from './common';
export const SUMMARY_TIMESLICES_7D_ROLLING: TransformPutTransformRequest = {
transform_id: `${SLO_SUMMARY_TRANSFORM_NAME_PREFIX}timeslices-7d-rolling`,
dest: {
index: SLO_SUMMARY_DESTINATION_INDEX_NAME,
pipeline: SLO_SUMMARY_INGEST_PIPELINE_NAME,
},
source: {
index: SLO_DESTINATION_INDEX_PATTERN,
runtime_mappings: {
errorBudgetEstimated: {
type: 'boolean',
script: 'emit(false)',
},
isTempDoc: {
type: 'boolean',
script: 'emit(false)',
},
},
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: 'now-7d/m',
lte: 'now/m',
},
},
},
{
term: {
'slo.budgetingMethod': 'timeslices',
},
},
{
term: {
'slo.timeWindow.type': 'rolling',
},
},
{
term: {
'slo.timeWindow.duration': '7d',
},
},
],
},
},
},
pivot: {
group_by: groupBy,
aggregations: {
goodEvents: {
sum: {
field: 'slo.isGoodSlice',
},
},
totalEvents: {
value_count: {
field: 'slo.isGoodSlice',
},
},
_objectiveTarget: {
max: {
field: 'slo.objective.target',
},
},
sliValue: {
bucket_script: {
buckets_path: {
goodEvents: 'goodEvents',
totalEvents: 'totalEvents',
},
script:
'if (params.totalEvents == 0) { return -1 } else { return params.goodEvents / params.totalEvents }',
},
},
errorBudgetInitial: {
bucket_script: {
buckets_path: {
objectiveTarget: '_objectiveTarget',
},
script: '1 - params.objectiveTarget',
},
},
errorBudgetConsumed: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
errorBudgetInitial: 'errorBudgetInitial',
},
script:
'if (params.sliValue == -1) { return 0 } else { return (1 - params.sliValue) / params.errorBudgetInitial }',
},
},
errorBudgetRemaining: {
bucket_script: {
buckets_path: {
errorBudgetConsummed: 'errorBudgetConsumed',
},
script: '1 - params.errorBudgetConsummed',
},
},
statusCode: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
objectiveTarget: '_objectiveTarget',
errorBudgetRemaining: 'errorBudgetRemaining',
},
script: {
source:
'if (params.sliValue == -1) { return 0 } else if (params.sliValue >= params.objectiveTarget) { return 4 } else if (params.errorBudgetRemaining > 0) { return 2 } else { return 1 }',
},
},
},
},
},
description:
'Summarize every SLO with timeslices budgeting method and a 7 days rolling time window',
frequency: '1m',
sync: {
time: {
field: '@timestamp',
delay: '125s',
},
},
settings: {
deduce_mappings: false,
},
_meta: {
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},
};

View file

@ -0,0 +1,152 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types';
import {
SLO_DESTINATION_INDEX_PATTERN,
SLO_RESOURCES_VERSION,
SLO_SUMMARY_DESTINATION_INDEX_NAME,
SLO_SUMMARY_INGEST_PIPELINE_NAME,
SLO_SUMMARY_TRANSFORM_NAME_PREFIX,
} from '../../../../assets/constants';
import { groupBy } from './common';
export const SUMMARY_TIMESLICES_90D_ROLLING: TransformPutTransformRequest = {
transform_id: `${SLO_SUMMARY_TRANSFORM_NAME_PREFIX}timeslices-90d-rolling`,
dest: {
index: SLO_SUMMARY_DESTINATION_INDEX_NAME,
pipeline: SLO_SUMMARY_INGEST_PIPELINE_NAME,
},
source: {
index: SLO_DESTINATION_INDEX_PATTERN,
runtime_mappings: {
errorBudgetEstimated: {
type: 'boolean',
script: 'emit(false)',
},
isTempDoc: {
type: 'boolean',
script: 'emit(false)',
},
},
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: 'now-90d/m',
lte: 'now/m',
},
},
},
{
term: {
'slo.budgetingMethod': 'timeslices',
},
},
{
term: {
'slo.timeWindow.type': 'rolling',
},
},
{
term: {
'slo.timeWindow.duration': '90d',
},
},
],
},
},
},
pivot: {
group_by: groupBy,
aggregations: {
goodEvents: {
sum: {
field: 'slo.isGoodSlice',
},
},
totalEvents: {
value_count: {
field: 'slo.isGoodSlice',
},
},
_objectiveTarget: {
max: {
field: 'slo.objective.target',
},
},
sliValue: {
bucket_script: {
buckets_path: {
goodEvents: 'goodEvents',
totalEvents: 'totalEvents',
},
script:
'if (params.totalEvents == 0) { return -1 } else { return params.goodEvents / params.totalEvents }',
},
},
errorBudgetInitial: {
bucket_script: {
buckets_path: {
objectiveTarget: '_objectiveTarget',
},
script: '1 - params.objectiveTarget',
},
},
errorBudgetConsumed: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
errorBudgetInitial: 'errorBudgetInitial',
},
script:
'if (params.sliValue == -1) { return 0 } else { return (1 - params.sliValue) / params.errorBudgetInitial }',
},
},
errorBudgetRemaining: {
bucket_script: {
buckets_path: {
errorBudgetConsummed: 'errorBudgetConsumed',
},
script: '1 - params.errorBudgetConsummed',
},
},
statusCode: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
objectiveTarget: '_objectiveTarget',
errorBudgetRemaining: 'errorBudgetRemaining',
},
script: {
source:
'if (params.sliValue == -1) { return 0 } else if (params.sliValue >= params.objectiveTarget) { return 4 } else if (params.errorBudgetRemaining > 0) { return 2 } else { return 1 }',
},
},
},
},
},
description:
'Summarize every SLO with timeslices budgeting method and a 90 days rolling time window',
frequency: '1m',
sync: {
time: {
field: '@timestamp',
delay: '125s',
},
},
settings: {
deduce_mappings: false,
},
_meta: {
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},
};

View file

@ -0,0 +1,180 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types';
import {
SLO_DESTINATION_INDEX_PATTERN,
SLO_RESOURCES_VERSION,
SLO_SUMMARY_DESTINATION_INDEX_NAME,
SLO_SUMMARY_INGEST_PIPELINE_NAME,
SLO_SUMMARY_TRANSFORM_NAME_PREFIX,
} from '../../../../assets/constants';
import { groupBy } from './common';
export const SUMMARY_TIMESLICES_MONTHLY_ALIGNED: TransformPutTransformRequest = {
transform_id: `${SLO_SUMMARY_TRANSFORM_NAME_PREFIX}timeslices-monthly-aligned`,
dest: {
index: SLO_SUMMARY_DESTINATION_INDEX_NAME,
pipeline: SLO_SUMMARY_INGEST_PIPELINE_NAME,
},
source: {
index: SLO_DESTINATION_INDEX_PATTERN,
runtime_mappings: {
errorBudgetEstimated: {
type: 'boolean',
script: 'emit(false)',
},
isTempDoc: {
type: 'boolean',
script: 'emit(false)',
},
},
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: 'now/M',
lte: 'now/m',
},
},
},
{
term: {
'slo.budgetingMethod': 'timeslices',
},
},
{
term: {
'slo.timeWindow.type': 'calendarAligned',
},
},
{
term: {
'slo.timeWindow.duration': '1M',
},
},
],
},
},
},
pivot: {
group_by: groupBy,
aggregations: {
_sliceDurationInSeconds: {
max: {
field: 'slo.objective.sliceDurationInSeconds',
},
},
_totalSlicesInPeriod: {
bucket_script: {
buckets_path: {
sliceDurationInSeconds: '_sliceDurationInSeconds',
},
script: {
source: `
Date d = new Date();
Instant instant = Instant.ofEpochMilli(d.getTime());
LocalDateTime now = LocalDateTime.ofInstant(instant, ZoneOffset.UTC);
LocalDateTime startOfMonth = now
.withDayOfMonth(1)
.withHour(0)
.withMinute(0)
.withSecond(0);
LocalDateTime startOfNextMonth = startOfMonth.plusMonths(1);
double sliceDurationInMinutes = params.sliceDurationInSeconds / 60;
return Math.ceil(Duration.between(startOfMonth, startOfNextMonth).toMinutes() / sliceDurationInMinutes);
`,
},
},
},
_objectiveTarget: {
max: {
field: 'slo.objective.target',
},
},
goodEvents: {
sum: {
field: 'slo.isGoodSlice',
},
},
totalEvents: {
value_count: {
field: 'slo.isGoodSlice',
},
},
sliValue: {
bucket_script: {
buckets_path: {
goodEvents: 'goodEvents',
totalEvents: 'totalEvents',
},
script:
'if (params.totalEvents == 0) { return -1 } else { return params.goodEvents / params.totalEvents }',
},
},
errorBudgetInitial: {
bucket_script: {
buckets_path: {
objective: '_objectiveTarget',
},
script: '1 - params.objective',
},
},
errorBudgetConsumed: {
bucket_script: {
buckets_path: {
goodEvents: 'goodEvents',
totalEvents: 'totalEvents',
totalSlicesInPeriod: '_totalSlicesInPeriod',
errorBudgetInitial: 'errorBudgetInitial',
},
script:
'if (params.totalEvents == 0) { return 0 } else { return (params.totalEvents - params.goodEvents) / (params.totalSlicesInPeriod * params.errorBudgetInitial) }',
},
},
errorBudgetRemaining: {
bucket_script: {
buckets_path: {
errorBudgetConsumed: 'errorBudgetConsumed',
},
script: '1 - params.errorBudgetConsumed',
},
},
statusCode: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
objective: '_objectiveTarget',
errorBudgetRemaining: 'errorBudgetRemaining',
},
script:
'if (params.sliValue == -1) { return 0 } else if (params.sliValue >= params.objective) { return 4 } else if (params.errorBudgetRemaining > 0) { return 2 } else { return 1 }',
},
},
},
},
description:
'Summarize every SLO with timeslices budgeting method and a monthly calendar aligned time window',
frequency: '1m',
sync: {
time: {
field: '@timestamp',
delay: '125s',
},
},
settings: {
deduce_mappings: false,
},
_meta: {
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},
};

View file

@ -0,0 +1,165 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types';
import {
SLO_DESTINATION_INDEX_PATTERN,
SLO_RESOURCES_VERSION,
SLO_SUMMARY_DESTINATION_INDEX_NAME,
SLO_SUMMARY_INGEST_PIPELINE_NAME,
SLO_SUMMARY_TRANSFORM_NAME_PREFIX,
} from '../../../../assets/constants';
import { groupBy } from './common';
export const SUMMARY_TIMESLICES_WEEKLY_ALIGNED: TransformPutTransformRequest = {
transform_id: `${SLO_SUMMARY_TRANSFORM_NAME_PREFIX}timeslices-weekly-aligned`,
dest: {
index: SLO_SUMMARY_DESTINATION_INDEX_NAME,
pipeline: SLO_SUMMARY_INGEST_PIPELINE_NAME,
},
source: {
index: SLO_DESTINATION_INDEX_PATTERN,
runtime_mappings: {
errorBudgetEstimated: {
type: 'boolean',
script: 'emit(false)',
},
isTempDoc: {
type: 'boolean',
script: 'emit(false)',
},
},
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: 'now/w',
lte: 'now/m',
},
},
},
{
term: {
'slo.budgetingMethod': 'timeslices',
},
},
{
term: {
'slo.timeWindow.type': 'calendarAligned',
},
},
{
term: {
'slo.timeWindow.duration': '1w',
},
},
],
},
},
},
pivot: {
group_by: groupBy,
aggregations: {
_sliceDurationInSeconds: {
max: {
field: 'slo.objective.sliceDurationInSeconds',
},
},
_totalSlicesInPeriod: {
bucket_script: {
buckets_path: {
sliceDurationInSeconds: '_sliceDurationInSeconds',
},
script: 'Math.ceil(7 * 24 * 60 * 60 / params.sliceDurationInSeconds)',
},
},
_objectiveTarget: {
max: {
field: 'slo.objective.target',
},
},
goodEvents: {
sum: {
field: 'slo.isGoodSlice',
},
},
totalEvents: {
value_count: {
field: 'slo.isGoodSlice',
},
},
sliValue: {
bucket_script: {
buckets_path: {
goodEvents: 'goodEvents',
totalEvents: 'totalEvents',
},
script:
'if (params.totalEvents == 0) { return -1 } else { return params.goodEvents / params.totalEvents }',
},
},
errorBudgetInitial: {
bucket_script: {
buckets_path: {
objective: '_objectiveTarget',
},
script: '1 - params.objective',
},
},
errorBudgetConsumed: {
bucket_script: {
buckets_path: {
goodEvents: 'goodEvents',
totalEvents: 'totalEvents',
totalSlicesInPeriod: '_totalSlicesInPeriod',
errorBudgetInitial: 'errorBudgetInitial',
},
script:
'if (params.totalEvents == 0) { return 0 } else { return (params.totalEvents - params.goodEvents) / (params.totalSlicesInPeriod * params.errorBudgetInitial) }',
},
},
errorBudgetRemaining: {
bucket_script: {
buckets_path: {
errorBudgetConsumed: 'errorBudgetConsumed',
},
script: '1 - params.errorBudgetConsumed',
},
},
statusCode: {
bucket_script: {
buckets_path: {
sliValue: 'sliValue',
objective: '_objectiveTarget',
errorBudgetRemaining: 'errorBudgetRemaining',
},
script:
'if (params.sliValue == -1) { return 0 } else if (params.sliValue >= params.objective) { return 4 } else if (params.errorBudgetRemaining > 0) { return 2 } else { return 1 }',
},
},
},
},
description:
'Summarize every SLO with timeslices budgeting method and a weekly calendar aligned time window',
frequency: '1m',
sync: {
time: {
field: '@timestamp',
delay: '125s',
},
},
settings: {
deduce_mappings: false,
},
_meta: {
version: SLO_RESOURCES_VERSION,
managed: true,
managed_by: 'observability',
},
};

View file

@ -139,17 +139,453 @@ Object {
}
`;
exports[`APM Transaction Duration Transform Generator groups by the 'service.environment' 1`] = `
Object {
"bool": Object {
"filter": Array [
Object {
"terms": Object {
"processor.event": Array [
"metric",
],
},
},
Object {
"term": Object {
"metricset.name": "transaction",
},
},
Object {
"exists": Object {
"field": "transaction.duration.histogram",
},
},
Object {
"range": Object {
"@timestamp": Object {
"gte": "now-7d",
},
},
},
Object {
"match": Object {
"service.environment": "production",
},
},
],
},
}
`;
exports[`APM Transaction Duration Transform Generator groups by the 'service.environment' 2`] = `
Object {
"@timestamp": Object {
"date_histogram": Object {
"field": "@timestamp",
"fixed_interval": "1m",
},
},
"service.environment": Object {
"terms": Object {
"field": "service.environment",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
}
`;
exports[`APM Transaction Duration Transform Generator groups by the 'service.name' 1`] = `
Object {
"bool": Object {
"filter": Array [
Object {
"terms": Object {
"processor.event": Array [
"metric",
],
},
},
Object {
"term": Object {
"metricset.name": "transaction",
},
},
Object {
"exists": Object {
"field": "transaction.duration.histogram",
},
},
Object {
"range": Object {
"@timestamp": Object {
"gte": "now-7d",
},
},
},
Object {
"match": Object {
"service.name": "my-service",
},
},
],
},
}
`;
exports[`APM Transaction Duration Transform Generator groups by the 'service.name' 2`] = `
Object {
"@timestamp": Object {
"date_histogram": Object {
"field": "@timestamp",
"fixed_interval": "1m",
},
},
"service.name": Object {
"terms": Object {
"field": "service.name",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
}
`;
exports[`APM Transaction Duration Transform Generator groups by the 'transaction.name' 1`] = `
Object {
"bool": Object {
"filter": Array [
Object {
"terms": Object {
"processor.event": Array [
"metric",
],
},
},
Object {
"term": Object {
"metricset.name": "transaction",
},
},
Object {
"exists": Object {
"field": "transaction.duration.histogram",
},
},
Object {
"range": Object {
"@timestamp": Object {
"gte": "now-7d",
},
},
},
Object {
"match": Object {
"transaction.name": "GET /foo",
},
},
],
},
}
`;
exports[`APM Transaction Duration Transform Generator groups by the 'transaction.name' 2`] = `
Object {
"@timestamp": Object {
"date_histogram": Object {
"field": "@timestamp",
"fixed_interval": "1m",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
"transaction.name": Object {
"terms": Object {
"field": "transaction.name",
},
},
}
`;
exports[`APM Transaction Duration Transform Generator groups by the 'transaction.type' 1`] = `
Object {
"bool": Object {
"filter": Array [
Object {
"terms": Object {
"processor.event": Array [
"metric",
],
},
},
Object {
"term": Object {
"metricset.name": "transaction",
},
},
Object {
"exists": Object {
"field": "transaction.duration.histogram",
},
},
Object {
"range": Object {
"@timestamp": Object {
"gte": "now-7d",
},
},
},
Object {
"match": Object {
"transaction.type": "request",
},
},
],
},
}
`;
exports[`APM Transaction Duration Transform Generator groups by the 'transaction.type' 2`] = `
Object {
"@timestamp": Object {
"date_histogram": Object {
"field": "@timestamp",
"fixed_interval": "1m",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
"transaction.type": Object {
"terms": Object {
"field": "transaction.type",
},
},
}
`;
exports[`APM Transaction Duration Transform Generator returns the expected transform params for timeslices slo 1`] = `
Object {
"_meta": Object {
"managed": true,
"managed_by": "observability",
"version": 1,
"version": 2,
},
"description": "Rolled-up SLI data for SLO: irrelevant",
"dest": Object {
"index": ".slo-observability.sli-v1",
"pipeline": ".slo-observability.sli.monthly",
"index": ".slo-observability.sli-v2",
"pipeline": ".slo-observability.sli.pipeline",
},
"frequency": "1m",
"pivot": Object {
@ -194,16 +630,86 @@ Object {
"fixed_interval": "2m",
},
},
"service.environment": Object {
"terms": Object {
"field": "service.environment",
},
},
"service.name": Object {
"terms": Object {
"field": "service.name",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.sliceDurationInSeconds": Object {
"terms": Object {
"field": "slo.objective.sliceDurationInSeconds",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
"transaction.name": Object {
"terms": Object {
"field": "transaction.name",
},
},
"transaction.type": Object {
"terms": Object {
"field": "transaction.type",
},
},
},
},
"settings": Object {
@ -262,18 +768,78 @@ Object {
},
},
"runtime_mappings": Object {
"slo.budgetingMethod": Object {
"script": Object {
"source": "emit('timeslices')",
},
"type": "keyword",
},
"slo.description": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
},
"type": "keyword",
},
"slo.indicator.type": Object {
"script": Object {
"source": "emit('sli.apm.transactionDuration')",
},
"type": "keyword",
},
"slo.instanceId": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.name": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.objective.sliceDurationInSeconds": Object {
"script": Object {
"source": "emit(120)",
},
"type": "long",
},
"slo.objective.target": Object {
"script": Object {
"source": "emit(0.98)",
},
"type": "double",
},
"slo.revision": Object {
"script": Object {
"source": "emit(1)",
},
"type": "long",
},
"slo.tags": Object {
"script": Object {
"source": "emit('critical,k8s')",
},
"type": "keyword",
},
"slo.timeWindow.duration": Object {
"script": Object {
"source": "emit('7d')",
},
"type": "keyword",
},
"slo.timeWindow.type": Object {
"script": Object {
"source": "emit('rolling')",
},
"type": "keyword",
},
},
},
"sync": Object {
@ -291,12 +857,12 @@ Object {
"_meta": Object {
"managed": true,
"managed_by": "observability",
"version": 1,
"version": 2,
},
"description": "Rolled-up SLI data for SLO: irrelevant",
"dest": Object {
"index": ".slo-observability.sli-v1",
"pipeline": ".slo-observability.sli.monthly",
"index": ".slo-observability.sli-v2",
"pipeline": ".slo-observability.sli.pipeline",
},
"frequency": "1m",
"pivot": Object {
@ -332,16 +898,81 @@ Object {
"fixed_interval": "1m",
},
},
"service.environment": Object {
"terms": Object {
"field": "service.environment",
},
},
"service.name": Object {
"terms": Object {
"field": "service.name",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
"transaction.name": Object {
"terms": Object {
"field": "transaction.name",
},
},
"transaction.type": Object {
"terms": Object {
"field": "transaction.type",
},
},
},
},
"settings": Object {
@ -400,18 +1031,72 @@ Object {
},
},
"runtime_mappings": Object {
"slo.budgetingMethod": Object {
"script": Object {
"source": "emit('occurrences')",
},
"type": "keyword",
},
"slo.description": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
},
"type": "keyword",
},
"slo.indicator.type": Object {
"script": Object {
"source": "emit('sli.apm.transactionDuration')",
},
"type": "keyword",
},
"slo.instanceId": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.name": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.objective.target": Object {
"script": Object {
"source": "emit(0.999)",
},
"type": "double",
},
"slo.revision": Object {
"script": Object {
"source": "emit(1)",
},
"type": "long",
},
"slo.tags": Object {
"script": Object {
"source": "emit('critical,k8s')",
},
"type": "keyword",
},
"slo.timeWindow.duration": Object {
"script": Object {
"source": "emit('7d')",
},
"type": "keyword",
},
"slo.timeWindow.type": Object {
"script": Object {
"source": "emit('rolling')",
},
"type": "keyword",
},
},
},
"sync": Object {

View file

@ -131,17 +131,437 @@ Object {
}
`;
exports[`APM Transaction Error Rate Transform Generator groups by the 'service.environment' 1`] = `
Object {
"bool": Object {
"filter": Array [
Object {
"term": Object {
"metricset.name": "transaction",
},
},
Object {
"terms": Object {
"event.outcome": Array [
"success",
"failure",
],
},
},
Object {
"range": Object {
"@timestamp": Object {
"gte": "now-7d",
},
},
},
Object {
"match": Object {
"service.environment": "production",
},
},
],
},
}
`;
exports[`APM Transaction Error Rate Transform Generator groups by the 'service.environment' 2`] = `
Object {
"@timestamp": Object {
"date_histogram": Object {
"field": "@timestamp",
"fixed_interval": "1m",
},
},
"service.environment": Object {
"terms": Object {
"field": "service.environment",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
}
`;
exports[`APM Transaction Error Rate Transform Generator groups by the 'service.name' 1`] = `
Object {
"bool": Object {
"filter": Array [
Object {
"term": Object {
"metricset.name": "transaction",
},
},
Object {
"terms": Object {
"event.outcome": Array [
"success",
"failure",
],
},
},
Object {
"range": Object {
"@timestamp": Object {
"gte": "now-7d",
},
},
},
Object {
"match": Object {
"service.name": "my-service",
},
},
],
},
}
`;
exports[`APM Transaction Error Rate Transform Generator groups by the 'service.name' 2`] = `
Object {
"@timestamp": Object {
"date_histogram": Object {
"field": "@timestamp",
"fixed_interval": "1m",
},
},
"service.name": Object {
"terms": Object {
"field": "service.name",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
}
`;
exports[`APM Transaction Error Rate Transform Generator groups by the 'transaction.name' 1`] = `
Object {
"bool": Object {
"filter": Array [
Object {
"term": Object {
"metricset.name": "transaction",
},
},
Object {
"terms": Object {
"event.outcome": Array [
"success",
"failure",
],
},
},
Object {
"range": Object {
"@timestamp": Object {
"gte": "now-7d",
},
},
},
Object {
"match": Object {
"transaction.name": "GET /foo",
},
},
],
},
}
`;
exports[`APM Transaction Error Rate Transform Generator groups by the 'transaction.name' 2`] = `
Object {
"@timestamp": Object {
"date_histogram": Object {
"field": "@timestamp",
"fixed_interval": "1m",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
"transaction.name": Object {
"terms": Object {
"field": "transaction.name",
},
},
}
`;
exports[`APM Transaction Error Rate Transform Generator groups by the 'transaction.type' 1`] = `
Object {
"bool": Object {
"filter": Array [
Object {
"term": Object {
"metricset.name": "transaction",
},
},
Object {
"terms": Object {
"event.outcome": Array [
"success",
"failure",
],
},
},
Object {
"range": Object {
"@timestamp": Object {
"gte": "now-7d",
},
},
},
Object {
"match": Object {
"transaction.type": "request",
},
},
],
},
}
`;
exports[`APM Transaction Error Rate Transform Generator groups by the 'transaction.type' 2`] = `
Object {
"@timestamp": Object {
"date_histogram": Object {
"field": "@timestamp",
"fixed_interval": "1m",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
"transaction.type": Object {
"terms": Object {
"field": "transaction.type",
},
},
}
`;
exports[`APM Transaction Error Rate Transform Generator returns the expected transform params for timeslices slo 1`] = `
Object {
"_meta": Object {
"managed": true,
"managed_by": "observability",
"version": 1,
"version": 2,
},
"description": "Rolled-up SLI data for SLO: irrelevant",
"dest": Object {
"index": ".slo-observability.sli-v1",
"pipeline": ".slo-observability.sli.monthly",
"index": ".slo-observability.sli-v2",
"pipeline": ".slo-observability.sli.pipeline",
},
"frequency": "1m",
"pivot": Object {
@ -179,16 +599,86 @@ Object {
"fixed_interval": "2m",
},
},
"service.environment": Object {
"terms": Object {
"field": "service.environment",
},
},
"service.name": Object {
"terms": Object {
"field": "service.name",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.sliceDurationInSeconds": Object {
"terms": Object {
"field": "slo.objective.sliceDurationInSeconds",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
"transaction.name": Object {
"terms": Object {
"field": "transaction.name",
},
},
"transaction.type": Object {
"terms": Object {
"field": "transaction.type",
},
},
},
},
"settings": Object {
@ -243,18 +733,78 @@ Object {
},
},
"runtime_mappings": Object {
"slo.budgetingMethod": Object {
"script": Object {
"source": "emit('timeslices')",
},
"type": "keyword",
},
"slo.description": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
},
"type": "keyword",
},
"slo.indicator.type": Object {
"script": Object {
"source": "emit('sli.apm.transactionErrorRate')",
},
"type": "keyword",
},
"slo.instanceId": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.name": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.objective.sliceDurationInSeconds": Object {
"script": Object {
"source": "emit(120)",
},
"type": "long",
},
"slo.objective.target": Object {
"script": Object {
"source": "emit(0.98)",
},
"type": "double",
},
"slo.revision": Object {
"script": Object {
"source": "emit(1)",
},
"type": "long",
},
"slo.tags": Object {
"script": Object {
"source": "emit('critical,k8s')",
},
"type": "keyword",
},
"slo.timeWindow.duration": Object {
"script": Object {
"source": "emit('7d')",
},
"type": "keyword",
},
"slo.timeWindow.type": Object {
"script": Object {
"source": "emit('rolling')",
},
"type": "keyword",
},
},
},
"sync": Object {
@ -272,12 +822,12 @@ Object {
"_meta": Object {
"managed": true,
"managed_by": "observability",
"version": 1,
"version": 2,
},
"description": "Rolled-up SLI data for SLO: irrelevant",
"dest": Object {
"index": ".slo-observability.sli-v1",
"pipeline": ".slo-observability.sli.monthly",
"index": ".slo-observability.sli-v2",
"pipeline": ".slo-observability.sli.pipeline",
},
"frequency": "1m",
"pivot": Object {
@ -306,16 +856,81 @@ Object {
"fixed_interval": "1m",
},
},
"service.environment": Object {
"terms": Object {
"field": "service.environment",
},
},
"service.name": Object {
"terms": Object {
"field": "service.name",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
"transaction.name": Object {
"terms": Object {
"field": "transaction.name",
},
},
"transaction.type": Object {
"terms": Object {
"field": "transaction.type",
},
},
},
},
"settings": Object {
@ -370,18 +985,72 @@ Object {
},
},
"runtime_mappings": Object {
"slo.budgetingMethod": Object {
"script": Object {
"source": "emit('occurrences')",
},
"type": "keyword",
},
"slo.description": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
},
"type": "keyword",
},
"slo.indicator.type": Object {
"script": Object {
"source": "emit('sli.apm.transactionErrorRate')",
},
"type": "keyword",
},
"slo.instanceId": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.name": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.objective.target": Object {
"script": Object {
"source": "emit(0.999)",
},
"type": "double",
},
"slo.revision": Object {
"script": Object {
"source": "emit(1)",
},
"type": "long",
},
"slo.tags": Object {
"script": Object {
"source": "emit('critical,k8s')",
},
"type": "keyword",
},
"slo.timeWindow.duration": Object {
"script": Object {
"source": "emit('7d')",
},
"type": "keyword",
},
"slo.timeWindow.type": Object {
"script": Object {
"source": "emit('rolling')",
},
"type": "keyword",
},
},
},
"sync": Object {

View file

@ -64,12 +64,12 @@ Object {
"_meta": Object {
"managed": true,
"managed_by": "observability",
"version": 1,
"version": 2,
},
"description": "Rolled-up SLI data for SLO: irrelevant",
"dest": Object {
"index": ".slo-observability.sli-v1",
"pipeline": ".slo-observability.sli.monthly",
"index": ".slo-observability.sli-v2",
"pipeline": ".slo-observability.sli.pipeline",
},
"frequency": "1m",
"pivot": Object {
@ -138,16 +138,66 @@ Object {
"fixed_interval": "2m",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.sliceDurationInSeconds": Object {
"terms": Object {
"field": "slo.objective.sliceDurationInSeconds",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
},
},
"settings": Object {
@ -168,18 +218,78 @@ Object {
},
},
"runtime_mappings": Object {
"slo.budgetingMethod": Object {
"script": Object {
"source": "emit('timeslices')",
},
"type": "keyword",
},
"slo.description": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
},
"type": "keyword",
},
"slo.indicator.type": Object {
"script": Object {
"source": "emit('sli.histogram.custom')",
},
"type": "keyword",
},
"slo.instanceId": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.name": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.objective.sliceDurationInSeconds": Object {
"script": Object {
"source": "emit(120)",
},
"type": "long",
},
"slo.objective.target": Object {
"script": Object {
"source": "emit(0.98)",
},
"type": "double",
},
"slo.revision": Object {
"script": Object {
"source": "emit(1)",
},
"type": "long",
},
"slo.tags": Object {
"script": Object {
"source": "emit('critical,k8s')",
},
"type": "keyword",
},
"slo.timeWindow.duration": Object {
"script": Object {
"source": "emit('7d')",
},
"type": "keyword",
},
"slo.timeWindow.type": Object {
"script": Object {
"source": "emit('rolling')",
},
"type": "keyword",
},
},
},
"sync": Object {
@ -197,12 +307,12 @@ Object {
"_meta": Object {
"managed": true,
"managed_by": "observability",
"version": 1,
"version": 2,
},
"description": "Rolled-up SLI data for SLO: irrelevant",
"dest": Object {
"index": ".slo-observability.sli-v1",
"pipeline": ".slo-observability.sli.monthly",
"index": ".slo-observability.sli-v2",
"pipeline": ".slo-observability.sli.pipeline",
},
"frequency": "1m",
"pivot": Object {
@ -262,16 +372,61 @@ Object {
"fixed_interval": "1m",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
},
},
"settings": Object {
@ -292,18 +447,72 @@ Object {
},
},
"runtime_mappings": Object {
"slo.budgetingMethod": Object {
"script": Object {
"source": "emit('occurrences')",
},
"type": "keyword",
},
"slo.description": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
},
"type": "keyword",
},
"slo.indicator.type": Object {
"script": Object {
"source": "emit('sli.histogram.custom')",
},
"type": "keyword",
},
"slo.instanceId": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.name": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.objective.target": Object {
"script": Object {
"source": "emit(0.999)",
},
"type": "double",
},
"slo.revision": Object {
"script": Object {
"source": "emit(1)",
},
"type": "long",
},
"slo.tags": Object {
"script": Object {
"source": "emit('critical,k8s')",
},
"type": "keyword",
},
"slo.timeWindow.duration": Object {
"script": Object {
"source": "emit('7d')",
},
"type": "keyword",
},
"slo.timeWindow.type": Object {
"script": Object {
"source": "emit('rolling')",
},
"type": "keyword",
},
},
},
"sync": Object {

View file

@ -105,12 +105,12 @@ Object {
"_meta": Object {
"managed": true,
"managed_by": "observability",
"version": 1,
"version": 2,
},
"description": "Rolled-up SLI data for SLO: irrelevant",
"dest": Object {
"index": ".slo-observability.sli-v1",
"pipeline": ".slo-observability.sli.monthly",
"index": ".slo-observability.sli-v2",
"pipeline": ".slo-observability.sli.pipeline",
},
"frequency": "1m",
"pivot": Object {
@ -153,16 +153,66 @@ Object {
"fixed_interval": "2m",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.sliceDurationInSeconds": Object {
"terms": Object {
"field": "slo.objective.sliceDurationInSeconds",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
},
},
"settings": Object {
@ -183,18 +233,78 @@ Object {
},
},
"runtime_mappings": Object {
"slo.budgetingMethod": Object {
"script": Object {
"source": "emit('timeslices')",
},
"type": "keyword",
},
"slo.description": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
},
"type": "keyword",
},
"slo.indicator.type": Object {
"script": Object {
"source": "emit('sli.kql.custom')",
},
"type": "keyword",
},
"slo.instanceId": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.name": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.objective.sliceDurationInSeconds": Object {
"script": Object {
"source": "emit(120)",
},
"type": "long",
},
"slo.objective.target": Object {
"script": Object {
"source": "emit(0.98)",
},
"type": "double",
},
"slo.revision": Object {
"script": Object {
"source": "emit(1)",
},
"type": "long",
},
"slo.tags": Object {
"script": Object {
"source": "emit('critical,k8s')",
},
"type": "keyword",
},
"slo.timeWindow.duration": Object {
"script": Object {
"source": "emit('7d')",
},
"type": "keyword",
},
"slo.timeWindow.type": Object {
"script": Object {
"source": "emit('rolling')",
},
"type": "keyword",
},
},
},
"sync": Object {
@ -212,12 +322,12 @@ Object {
"_meta": Object {
"managed": true,
"managed_by": "observability",
"version": 1,
"version": 2,
},
"description": "Rolled-up SLI data for SLO: irrelevant",
"dest": Object {
"index": ".slo-observability.sli-v1",
"pipeline": ".slo-observability.sli.monthly",
"index": ".slo-observability.sli-v2",
"pipeline": ".slo-observability.sli.pipeline",
},
"frequency": "1m",
"pivot": Object {
@ -251,16 +361,61 @@ Object {
"fixed_interval": "1m",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
},
},
"settings": Object {
@ -281,18 +436,72 @@ Object {
},
},
"runtime_mappings": Object {
"slo.budgetingMethod": Object {
"script": Object {
"source": "emit('occurrences')",
},
"type": "keyword",
},
"slo.description": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
},
"type": "keyword",
},
"slo.indicator.type": Object {
"script": Object {
"source": "emit('sli.kql.custom')",
},
"type": "keyword",
},
"slo.instanceId": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.name": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.objective.target": Object {
"script": Object {
"source": "emit(0.999)",
},
"type": "double",
},
"slo.revision": Object {
"script": Object {
"source": "emit(1)",
},
"type": "long",
},
"slo.tags": Object {
"script": Object {
"source": "emit('critical,k8s')",
},
"type": "keyword",
},
"slo.timeWindow.duration": Object {
"script": Object {
"source": "emit('7d')",
},
"type": "keyword",
},
"slo.timeWindow.type": Object {
"script": Object {
"source": "emit('rolling')",
},
"type": "keyword",
},
},
},
"sync": Object {

View file

@ -76,12 +76,12 @@ Object {
"_meta": Object {
"managed": true,
"managed_by": "observability",
"version": 1,
"version": 2,
},
"description": "Rolled-up SLI data for SLO: irrelevant",
"dest": Object {
"index": ".slo-observability.sli-v1",
"pipeline": ".slo-observability.sli.monthly",
"index": ".slo-observability.sli-v2",
"pipeline": ".slo-observability.sli.pipeline",
},
"frequency": "1m",
"pivot": Object {
@ -162,16 +162,66 @@ Object {
"fixed_interval": "2m",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.sliceDurationInSeconds": Object {
"terms": Object {
"field": "slo.objective.sliceDurationInSeconds",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
},
},
"settings": Object {
@ -192,18 +242,78 @@ Object {
},
},
"runtime_mappings": Object {
"slo.budgetingMethod": Object {
"script": Object {
"source": "emit('timeslices')",
},
"type": "keyword",
},
"slo.description": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
},
"type": "keyword",
},
"slo.indicator.type": Object {
"script": Object {
"source": "emit('sli.metric.custom')",
},
"type": "keyword",
},
"slo.instanceId": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.name": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.objective.sliceDurationInSeconds": Object {
"script": Object {
"source": "emit(120)",
},
"type": "long",
},
"slo.objective.target": Object {
"script": Object {
"source": "emit(0.98)",
},
"type": "double",
},
"slo.revision": Object {
"script": Object {
"source": "emit(1)",
},
"type": "long",
},
"slo.tags": Object {
"script": Object {
"source": "emit('critical,k8s')",
},
"type": "keyword",
},
"slo.timeWindow.duration": Object {
"script": Object {
"source": "emit('7d')",
},
"type": "keyword",
},
"slo.timeWindow.type": Object {
"script": Object {
"source": "emit('rolling')",
},
"type": "keyword",
},
},
},
"sync": Object {
@ -221,12 +331,12 @@ Object {
"_meta": Object {
"managed": true,
"managed_by": "observability",
"version": 1,
"version": 2,
},
"description": "Rolled-up SLI data for SLO: irrelevant",
"dest": Object {
"index": ".slo-observability.sli-v1",
"pipeline": ".slo-observability.sli.monthly",
"index": ".slo-observability.sli-v2",
"pipeline": ".slo-observability.sli.pipeline",
},
"frequency": "1m",
"pivot": Object {
@ -298,16 +408,61 @@ Object {
"fixed_interval": "1m",
},
},
"slo.budgetingMethod": Object {
"terms": Object {
"field": "slo.budgetingMethod",
},
},
"slo.description": Object {
"terms": Object {
"field": "slo.description",
},
},
"slo.id": Object {
"terms": Object {
"field": "slo.id",
},
},
"slo.indicator.type": Object {
"terms": Object {
"field": "slo.indicator.type",
},
},
"slo.instanceId": Object {
"terms": Object {
"field": "slo.instanceId",
},
},
"slo.name": Object {
"terms": Object {
"field": "slo.name",
},
},
"slo.objective.target": Object {
"terms": Object {
"field": "slo.objective.target",
},
},
"slo.revision": Object {
"terms": Object {
"field": "slo.revision",
},
},
"slo.tags": Object {
"terms": Object {
"field": "slo.tags",
},
},
"slo.timeWindow.duration": Object {
"terms": Object {
"field": "slo.timeWindow.duration",
},
},
"slo.timeWindow.type": Object {
"terms": Object {
"field": "slo.timeWindow.type",
},
},
},
},
"settings": Object {
@ -328,18 +483,72 @@ Object {
},
},
"runtime_mappings": Object {
"slo.budgetingMethod": Object {
"script": Object {
"source": "emit('occurrences')",
},
"type": "keyword",
},
"slo.description": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.id": Object {
"script": Object {
"source": Any<String>,
},
"type": "keyword",
},
"slo.indicator.type": Object {
"script": Object {
"source": "emit('sli.metric.custom')",
},
"type": "keyword",
},
"slo.instanceId": Object {
"script": Object {
"source": "emit('*')",
},
"type": "keyword",
},
"slo.name": Object {
"script": Object {
"source": "emit('irrelevant')",
},
"type": "keyword",
},
"slo.objective.target": Object {
"script": Object {
"source": "emit(0.999)",
},
"type": "double",
},
"slo.revision": Object {
"script": Object {
"source": "emit(1)",
},
"type": "long",
},
"slo.tags": Object {
"script": Object {
"source": "emit('critical,k8s')",
},
"type": "keyword",
},
"slo.timeWindow.duration": Object {
"script": Object {
"source": "emit('7d')",
},
"type": "keyword",
},
"slo.timeWindow.type": Object {
"script": Object {
"source": "emit('rolling')",
},
"type": "keyword",
},
},
},
"sync": Object {

View file

@ -15,28 +15,28 @@ import { ApmTransactionDurationTransformGenerator } from './apm_transaction_dura
const generator = new ApmTransactionDurationTransformGenerator();
describe('APM Transaction Duration Transform Generator', () => {
it('returns the expected transform params with every specified indicator params', async () => {
const anSLO = createSLO({ indicator: createAPMTransactionDurationIndicator() });
const transform = generator.getTransformParams(anSLO);
it('returns the expected transform params with every specified indicator params', () => {
const slo = createSLO({ indicator: createAPMTransactionDurationIndicator() });
const transform = generator.getTransformParams(slo);
expect(transform).toMatchSnapshot({
transform_id: expect.any(String),
source: { runtime_mappings: { 'slo.id': { script: { source: expect.any(String) } } } },
});
expect(transform.transform_id).toEqual(`slo-${anSLO.id}-${anSLO.revision}`);
expect(transform.transform_id).toEqual(`slo-${slo.id}-${slo.revision}`);
expect(transform.source.runtime_mappings!['slo.id']).toMatchObject({
script: { source: `emit('${anSLO.id}')` },
script: { source: `emit('${slo.id}')` },
});
expect(transform.source.runtime_mappings!['slo.revision']).toMatchObject({
script: { source: `emit(${anSLO.revision})` },
script: { source: `emit(${slo.revision})` },
});
});
it('returns the expected transform params for timeslices slo', async () => {
const anSLO = createSLOWithTimeslicesBudgetingMethod({
it('returns the expected transform params for timeslices slo', () => {
const slo = createSLOWithTimeslicesBudgetingMethod({
indicator: createAPMTransactionDurationIndicator(),
});
const transform = generator.getTransformParams(anSLO);
const transform = generator.getTransformParams(slo);
expect(transform).toMatchSnapshot({
transform_id: expect.any(String),
@ -44,8 +44,8 @@ describe('APM Transaction Duration Transform Generator', () => {
});
});
it("does not include the query filter when params are '*'", async () => {
const anSLO = createSLO({
it("does not include the query filter when params are '*'", () => {
const slo = createSLO({
indicator: createAPMTransactionDurationIndicator({
environment: '*',
service: '*',
@ -53,32 +53,96 @@ describe('APM Transaction Duration Transform Generator', () => {
transactionType: '*',
}),
});
const transform = generator.getTransformParams(anSLO);
const transform = generator.getTransformParams(slo);
expect(transform.source.query).toMatchSnapshot();
});
it('uses the provided index params as source index', async () => {
it('uses the provided index params as source index', () => {
const index = 'my-custom-apm-index*';
const anSLO = createSLO({
const slo = createSLO({
indicator: createAPMTransactionDurationIndicator({
index,
}),
});
const transform = generator.getTransformParams(anSLO);
const transform = generator.getTransformParams(slo);
expect(transform.source.index).toEqual(index);
});
it('adds the custom kql filter to the query', async () => {
it('adds the custom kql filter to the query', () => {
const filter = `"my.field" : "value" and ("foo" >= 12 or "bar" <= 100)`;
const anSLO = createSLO({
const slo = createSLO({
indicator: createAPMTransactionDurationIndicator({
filter,
}),
});
const transform = generator.getTransformParams(anSLO);
const transform = generator.getTransformParams(slo);
expect(transform.source.query).toMatchSnapshot();
});
it("groups by the 'service.name'", () => {
const slo = createSLO({
indicator: createAPMTransactionDurationIndicator({
service: 'my-service',
environment: '*',
transactionName: '*',
transactionType: '*',
}),
});
const transform = generator.getTransformParams(slo);
expect(transform.source.query).toMatchSnapshot();
expect(transform.pivot?.group_by).toMatchSnapshot();
});
it("groups by the 'service.environment'", () => {
const slo = createSLO({
indicator: createAPMTransactionDurationIndicator({
service: '*',
environment: 'production',
transactionName: '*',
transactionType: '*',
}),
});
const transform = generator.getTransformParams(slo);
expect(transform.source.query).toMatchSnapshot();
expect(transform.pivot?.group_by).toMatchSnapshot();
});
it("groups by the 'transaction.name'", () => {
const slo = createSLO({
indicator: createAPMTransactionDurationIndicator({
service: '*',
environment: '*',
transactionName: 'GET /foo',
transactionType: '*',
}),
});
const transform = generator.getTransformParams(slo);
expect(transform.source.query).toMatchSnapshot();
expect(transform.pivot?.group_by).toMatchSnapshot();
});
it("groups by the 'transaction.type'", () => {
const slo = createSLO({
indicator: createAPMTransactionDurationIndicator({
service: '*',
environment: '*',
transactionName: '*',
transactionType: 'request',
}),
});
const transform = generator.getTransformParams(slo);
expect(transform.source.query).toMatchSnapshot();
expect(transform.pivot?.group_by).toMatchSnapshot();
});
});

View file

@ -11,17 +11,17 @@ import {
apmTransactionDurationIndicatorSchema,
timeslicesBudgetingMethodSchema,
} from '@kbn/slo-schema';
import { InvalidTransformError } from '../../../errors';
import { getElastichsearchQueryOrThrow, TransformGenerator } from '.';
import {
getSLOTransformId,
SLO_DESTINATION_INDEX_NAME,
SLO_INGEST_PIPELINE_NAME,
getSLOTransformId,
} from '../../../assets/constants';
import { getSLOTransformTemplate } from '../../../assets/transform_templates/slo_transform_template';
import { SLO, APMTransactionDurationIndicator } from '../../../domain/models';
import { getElastichsearchQueryOrThrow, TransformGenerator } from '.';
import { Query } from './types';
import { APMTransactionDurationIndicator, SLO } from '../../../domain/models';
import { InvalidTransformError } from '../../../errors';
import { parseIndex } from './common';
import { Query } from './types';
export class ApmTransactionDurationTransformGenerator extends TransformGenerator {
public getTransformParams(slo: SLO): TransformPutTransformRequest {
@ -34,7 +34,7 @@ export class ApmTransactionDurationTransformGenerator extends TransformGenerator
this.buildDescription(slo),
this.buildSource(slo, slo.indicator),
this.buildDestination(),
this.buildGroupBy(slo),
this.buildGroupBy(slo, slo.indicator),
this.buildAggregations(slo, slo.indicator),
this.buildSettings(slo)
);
@ -44,6 +44,29 @@ export class ApmTransactionDurationTransformGenerator extends TransformGenerator
return getSLOTransformId(slo.id, slo.revision);
}
private buildGroupBy(slo: SLO, indicator: APMTransactionDurationIndicator) {
// These groupBy fields must match the fields from the source query, otherwise
// the transform will create permutations for each value present in the source.
// E.g. if environment is not specified in the source query, but we include it in the groupBy,
// we'll output documents for each environment value
const extraGroupByFields = {
...(indicator.params.service !== ALL_VALUE && {
'service.name': { terms: { field: 'service.name' } },
}),
...(indicator.params.environment !== ALL_VALUE && {
'service.environment': { terms: { field: 'service.environment' } },
}),
...(indicator.params.transactionName !== ALL_VALUE && {
'transaction.name': { terms: { field: 'transaction.name' } },
}),
...(indicator.params.transactionType !== ALL_VALUE && {
'transaction.type': { terms: { field: 'transaction.type' } },
}),
};
return this.buildCommonGroupBy(slo, '@timestamp', extraGroupByFields);
}
private buildSource(slo: SLO, indicator: APMTransactionDurationIndicator) {
const queryFilter: Query[] = [
{
@ -54,6 +77,7 @@ export class ApmTransactionDurationTransformGenerator extends TransformGenerator
},
},
];
if (indicator.params.service !== ALL_VALUE) {
queryFilter.push({
match: {

View file

@ -16,27 +16,27 @@ const generator = new ApmTransactionErrorRateTransformGenerator();
describe('APM Transaction Error Rate Transform Generator', () => {
it('returns the expected transform params with every specified indicator params', async () => {
const anSLO = createSLO({ indicator: createAPMTransactionErrorRateIndicator() });
const transform = generator.getTransformParams(anSLO);
const slo = createSLO({ indicator: createAPMTransactionErrorRateIndicator() });
const transform = generator.getTransformParams(slo);
expect(transform).toMatchSnapshot({
transform_id: expect.any(String),
source: { runtime_mappings: { 'slo.id': { script: { source: expect.any(String) } } } },
});
expect(transform.transform_id).toEqual(`slo-${anSLO.id}-${anSLO.revision}`);
expect(transform.transform_id).toEqual(`slo-${slo.id}-${slo.revision}`);
expect(transform.source.runtime_mappings!['slo.id']).toMatchObject({
script: { source: `emit('${anSLO.id}')` },
script: { source: `emit('${slo.id}')` },
});
expect(transform.source.runtime_mappings!['slo.revision']).toMatchObject({
script: { source: `emit(${anSLO.revision})` },
script: { source: `emit(${slo.revision})` },
});
});
it('returns the expected transform params for timeslices slo', async () => {
const anSLO = createSLOWithTimeslicesBudgetingMethod({
const slo = createSLOWithTimeslicesBudgetingMethod({
indicator: createAPMTransactionErrorRateIndicator(),
});
const transform = generator.getTransformParams(anSLO);
const transform = generator.getTransformParams(slo);
expect(transform).toMatchSnapshot({
transform_id: expect.any(String),
@ -45,7 +45,7 @@ describe('APM Transaction Error Rate Transform Generator', () => {
});
it("does not include the query filter when params are '*'", async () => {
const anSLO = createSLO({
const slo = createSLO({
indicator: createAPMTransactionErrorRateIndicator({
environment: '*',
service: '*',
@ -53,32 +53,96 @@ describe('APM Transaction Error Rate Transform Generator', () => {
transactionType: '*',
}),
});
const transform = generator.getTransformParams(anSLO);
const transform = generator.getTransformParams(slo);
expect(transform.source.query).toMatchSnapshot();
});
it('uses the provided index params as source index', async () => {
const index = 'my-custom-apm-index*';
const anSLO = createSLO({
const slo = createSLO({
indicator: createAPMTransactionErrorRateIndicator({
index,
}),
});
const transform = generator.getTransformParams(anSLO);
const transform = generator.getTransformParams(slo);
expect(transform.source.index).toEqual(index);
});
it('adds the custom kql filter to the query', async () => {
const filter = `"my.field" : "value" and ("foo" >= 12 or "bar" <= 100)`;
const anSLO = createSLO({
const slo = createSLO({
indicator: createAPMTransactionErrorRateIndicator({
filter,
}),
});
const transform = generator.getTransformParams(anSLO);
const transform = generator.getTransformParams(slo);
expect(transform.source.query).toMatchSnapshot();
});
it("groups by the 'service.name'", () => {
const slo = createSLO({
indicator: createAPMTransactionErrorRateIndicator({
service: 'my-service',
environment: '*',
transactionName: '*',
transactionType: '*',
}),
});
const transform = generator.getTransformParams(slo);
expect(transform.source.query).toMatchSnapshot();
expect(transform.pivot?.group_by).toMatchSnapshot();
});
it("groups by the 'service.environment'", () => {
const slo = createSLO({
indicator: createAPMTransactionErrorRateIndicator({
service: '*',
environment: 'production',
transactionName: '*',
transactionType: '*',
}),
});
const transform = generator.getTransformParams(slo);
expect(transform.source.query).toMatchSnapshot();
expect(transform.pivot?.group_by).toMatchSnapshot();
});
it("groups by the 'transaction.name'", () => {
const slo = createSLO({
indicator: createAPMTransactionErrorRateIndicator({
service: '*',
environment: '*',
transactionName: 'GET /foo',
transactionType: '*',
}),
});
const transform = generator.getTransformParams(slo);
expect(transform.source.query).toMatchSnapshot();
expect(transform.pivot?.group_by).toMatchSnapshot();
});
it("groups by the 'transaction.type'", () => {
const slo = createSLO({
indicator: createAPMTransactionErrorRateIndicator({
service: '*',
environment: '*',
transactionName: '*',
transactionType: 'request',
}),
});
const transform = generator.getTransformParams(slo);
expect(transform.source.query).toMatchSnapshot();
expect(transform.pivot?.group_by).toMatchSnapshot();
});
});

View file

@ -11,18 +11,17 @@ import {
apmTransactionErrorRateIndicatorSchema,
timeslicesBudgetingMethodSchema,
} from '@kbn/slo-schema';
import { InvalidTransformError } from '../../../errors';
import { getSLOTransformTemplate } from '../../../assets/transform_templates/slo_transform_template';
import { getElastichsearchQueryOrThrow, TransformGenerator } from '.';
import {
getSLOTransformId,
SLO_DESTINATION_INDEX_NAME,
SLO_INGEST_PIPELINE_NAME,
getSLOTransformId,
} from '../../../assets/constants';
import { getSLOTransformTemplate } from '../../../assets/transform_templates/slo_transform_template';
import { APMTransactionErrorRateIndicator, SLO } from '../../../domain/models';
import { Query } from './types';
import { InvalidTransformError } from '../../../errors';
import { parseIndex } from './common';
import { Query } from './types';
export class ApmTransactionErrorRateTransformGenerator extends TransformGenerator {
public getTransformParams(slo: SLO): TransformPutTransformRequest {
@ -35,7 +34,7 @@ export class ApmTransactionErrorRateTransformGenerator extends TransformGenerato
this.buildDescription(slo),
this.buildSource(slo, slo.indicator),
this.buildDestination(),
this.buildGroupBy(slo),
this.buildGroupBy(slo, slo.indicator),
this.buildAggregations(slo),
this.buildSettings(slo)
);
@ -45,6 +44,29 @@ export class ApmTransactionErrorRateTransformGenerator extends TransformGenerato
return getSLOTransformId(slo.id, slo.revision);
}
private buildGroupBy(slo: SLO, indicator: APMTransactionErrorRateIndicator) {
// These groupBy fields must match the fields from the source query, otherwise
// the transform will create permutations for each value present in the source.
// E.g. if environment is not specified in the source query, but we include it in the groupBy,
// we'll output documents for each environment value
const extraGroupByFields = {
...(indicator.params.service !== ALL_VALUE && {
'service.name': { terms: { field: 'service.name' } },
}),
...(indicator.params.environment !== ALL_VALUE && {
'service.environment': { terms: { field: 'service.environment' } },
}),
...(indicator.params.transactionName !== ALL_VALUE && {
'transaction.name': { terms: { field: 'transaction.name' } },
}),
...(indicator.params.transactionType !== ALL_VALUE && {
'transaction.type': { terms: { field: 'transaction.type' } },
}),
};
return this.buildCommonGroupBy(slo, '@timestamp', extraGroupByFields);
}
private buildSource(slo: SLO, indicator: APMTransactionErrorRateIndicator) {
const queryFilter: Query[] = [
{

View file

@ -34,7 +34,7 @@ export class HistogramTransformGenerator extends TransformGenerator {
this.buildDescription(slo),
this.buildSource(slo, slo.indicator),
this.buildDestination(),
this.buildGroupBy(slo, slo.indicator.params.timestampField),
this.buildCommonGroupBy(slo, slo.indicator.params.timestampField),
this.buildAggregations(slo, slo.indicator),
this.buildSettings(slo, slo.indicator.params.timestampField)
);

View file

@ -29,7 +29,7 @@ export class KQLCustomTransformGenerator extends TransformGenerator {
this.buildDescription(slo),
this.buildSource(slo, slo.indicator),
this.buildDestination(),
this.buildGroupBy(slo, slo.indicator.params.timestampField),
this.buildCommonGroupBy(slo, slo.indicator.params.timestampField),
this.buildAggregations(slo, slo.indicator),
this.buildSettings(slo, slo.indicator.params.timestampField)
);

View file

@ -32,7 +32,7 @@ export class MetricCustomTransformGenerator extends TransformGenerator {
this.buildDescription(slo),
this.buildSource(slo, slo.indicator),
this.buildDestination(),
this.buildGroupBy(slo, slo.indicator.params.timestampField),
this.buildCommonGroupBy(slo, slo.indicator.params.timestampField),
this.buildAggregations(slo, slo.indicator),
this.buildSettings(slo, slo.indicator.params.timestampField)
);

View file

@ -5,30 +5,93 @@
* 2.0.
*/
import { MappingRuntimeFieldType } from '@elastic/elasticsearch/lib/api/types';
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { timeslicesBudgetingMethodSchema } from '@kbn/slo-schema';
import {
MappingRuntimeFields,
TransformPutTransformRequest,
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { ALL_VALUE, timeslicesBudgetingMethodSchema } from '@kbn/slo-schema';
import { TransformSettings } from '../../../assets/transform_templates/slo_transform_template';
import { SLO } from '../../../domain/models';
export abstract class TransformGenerator {
public abstract getTransformParams(slo: SLO): TransformPutTransformRequest;
public buildCommonRuntimeMappings(slo: SLO) {
public buildCommonRuntimeMappings(slo: SLO): MappingRuntimeFields {
return {
'slo.id': {
type: 'keyword' as MappingRuntimeFieldType,
type: 'keyword',
script: {
source: `emit('${slo.id}')`,
},
},
'slo.revision': {
type: 'long' as MappingRuntimeFieldType,
type: 'long',
script: {
source: `emit(${slo.revision})`,
},
},
'slo.instanceId': {
type: 'keyword',
script: {
source: `emit('${ALL_VALUE}')`,
},
},
'slo.name': {
type: 'keyword',
script: {
source: `emit('${slo.name}')`,
},
},
'slo.description': {
type: 'keyword',
script: {
source: `emit('${slo.description}')`,
},
},
'slo.tags': {
type: 'keyword',
script: {
source: `emit('${slo.tags}')`,
},
},
'slo.indicator.type': {
type: 'keyword',
script: {
source: `emit('${slo.indicator.type}')`,
},
},
'slo.objective.target': {
type: 'double',
script: {
source: `emit(${slo.objective.target})`,
},
},
...(slo.objective.timesliceWindow && {
'slo.objective.sliceDurationInSeconds': {
type: 'long',
script: {
source: `emit(${slo.objective.timesliceWindow!.asSeconds()})`,
},
},
}),
'slo.budgetingMethod': {
type: 'keyword',
script: {
source: `emit('${slo.budgetingMethod}')`,
},
},
'slo.timeWindow.duration': {
type: 'keyword',
script: {
source: `emit('${slo.timeWindow.duration.format()}')`,
},
},
'slo.timeWindow.type': {
type: 'keyword',
script: {
source: `emit('${slo.timeWindow.type}')`,
},
},
};
}
@ -36,24 +99,35 @@ export abstract class TransformGenerator {
return `Rolled-up SLI data for SLO: ${slo.name}`;
}
public buildGroupBy(slo: SLO, sourceIndexTimestampField: string | undefined = '@timestamp') {
public buildCommonGroupBy(
slo: SLO,
sourceIndexTimestampField: string | undefined = '@timestamp',
extraGroupByFields = {}
) {
let fixedInterval = '1m';
if (timeslicesBudgetingMethodSchema.is(slo.budgetingMethod)) {
fixedInterval = slo.objective.timesliceWindow!.format();
}
return {
'slo.id': {
terms: {
field: 'slo.id',
'slo.id': { terms: { field: 'slo.id' } },
'slo.revision': { terms: { field: 'slo.revision' } },
'slo.instanceId': { terms: { field: 'slo.instanceId' } },
'slo.name': { terms: { field: 'slo.name' } },
'slo.description': { terms: { field: 'slo.description' } },
'slo.tags': { terms: { field: 'slo.tags' } },
'slo.indicator.type': { terms: { field: 'slo.indicator.type' } },
'slo.objective.target': { terms: { field: 'slo.objective.target' } },
...(slo.objective.timesliceWindow && {
'slo.objective.sliceDurationInSeconds': {
terms: { field: 'slo.objective.sliceDurationInSeconds' },
},
},
'slo.revision': {
terms: {
field: 'slo.revision',
},
},
// timestamp field defined in the destination index
}),
'slo.budgetingMethod': { terms: { field: 'slo.budgetingMethod' } },
'slo.timeWindow.duration': { terms: { field: 'slo.timeWindow.duration' } },
'slo.timeWindow.type': { terms: { field: 'slo.timeWindow.type' } },
...extraGroupByFields,
// @timestamp field defined in the destination index
'@timestamp': {
date_histogram: {
field: sourceIndexTimestampField, // timestamp field defined in the source index

View file

@ -8,7 +8,11 @@
import { ElasticsearchClient } from '@kbn/core/server';
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { getSLOTransformId } from '../../assets/constants';
import {
getSLOTransformId,
SLO_DESTINATION_INDEX_PATTERN,
SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
} from '../../assets/constants';
import { SLO } from '../../domain/models';
import { fiveMinute, oneMinute } from './fixtures/duration';
import {
@ -34,131 +38,107 @@ describe('UpdateSLO', () => {
updateSLO = new UpdateSLO(mockRepository, mockTransformManager, mockEsClient);
});
describe('without breaking changes', () => {
it('updates the SLO saved object without revision bump', async () => {
const slo = createSLO({ indicator: createAPMTransactionErrorRateIndicator() });
mockRepository.findById.mockResolvedValueOnce(slo);
it('updates the settings correctly', async () => {
const slo = createSLO();
mockRepository.findById.mockResolvedValueOnce(slo);
const newName = 'new slo name';
const newTags = ['other', 'tags'];
const response = await updateSLO.execute(slo.id, { name: newName, tags: newTags });
const newSettings = { ...slo.settings, timestamp_field: 'newField' };
await updateSLO.execute(slo.id, { settings: newSettings });
expectTransformManagerNeverCalled();
expect(mockEsClient.deleteByQuery).not.toBeCalled();
expect(mockRepository.save).toBeCalledWith(
expect.objectContaining({
...slo,
name: newName,
tags: newTags,
updatedAt: expect.anything(),
})
);
expect(slo.name).not.toEqual(newName);
expect(response.name).toEqual(newName);
expect(response.updatedAt).not.toEqual(slo.updatedAt);
expect(response.revision).toEqual(slo.revision);
expect(response.tags).toEqual(newTags);
expect(slo.tags).not.toEqual(newTags);
});
expectDeletionOfObsoleteSLOData(slo);
expect(mockRepository.save).toBeCalledWith(
expect.objectContaining({
...slo,
settings: newSettings,
revision: 2,
updatedAt: expect.anything(),
})
);
expectInstallationOfNewSLOTransform();
});
describe('with breaking changes', () => {
it('consideres settings as a breaking change', async () => {
const slo = createSLO();
mockRepository.findById.mockResolvedValueOnce(slo);
it('updates the budgeting method correctly', async () => {
const slo = createSLO({ budgetingMethod: 'occurrences' });
mockRepository.findById.mockResolvedValueOnce(slo);
const newSettings = { ...slo.settings, timestamp_field: 'newField' };
await updateSLO.execute(slo.id, { settings: newSettings });
expectDeletionOfObsoleteSLOData(slo);
expect(mockRepository.save).toBeCalledWith(
expect.objectContaining({
...slo,
settings: newSettings,
revision: 2,
updatedAt: expect.anything(),
})
);
expectInstallationOfNewSLOTransform();
await updateSLO.execute(slo.id, {
budgetingMethod: 'timeslices',
objective: {
target: slo.objective.target,
timesliceTarget: 0.9,
timesliceWindow: oneMinute(),
},
});
it('consideres a budgeting method change as a breaking change', async () => {
const slo = createSLO({ budgetingMethod: 'occurrences' });
mockRepository.findById.mockResolvedValueOnce(slo);
await updateSLO.execute(slo.id, {
budgetingMethod: 'timeslices',
objective: {
target: slo.objective.target,
timesliceTarget: 0.9,
timesliceWindow: oneMinute(),
},
});
expectDeletionOfObsoleteSLOData(slo);
expectInstallationOfNewSLOTransform();
});
it('consideres a timeslice target change as a breaking change', async () => {
const slo = createSLOWithTimeslicesBudgetingMethod();
mockRepository.findById.mockResolvedValueOnce(slo);
await updateSLO.execute(slo.id, {
objective: {
target: slo.objective.target,
timesliceTarget: 0.1,
timesliceWindow: slo.objective.timesliceWindow,
},
});
expectDeletionOfObsoleteSLOData(slo);
expectInstallationOfNewSLOTransform();
});
it('consideres a timeslice window change as a breaking change', async () => {
const slo = createSLOWithTimeslicesBudgetingMethod();
mockRepository.findById.mockResolvedValueOnce(slo);
await updateSLO.execute(slo.id, {
objective: {
target: slo.objective.target,
timesliceTarget: slo.objective.timesliceTarget,
timesliceWindow: fiveMinute(),
},
});
expectDeletionOfObsoleteSLOData(slo);
expectInstallationOfNewSLOTransform();
});
it('removes the obsolete data from the SLO previous revision', async () => {
const slo = createSLO({
indicator: createAPMTransactionErrorRateIndicator({ environment: 'development' }),
});
mockRepository.findById.mockResolvedValueOnce(slo);
const newIndicator = createAPMTransactionErrorRateIndicator({ environment: 'production' });
await updateSLO.execute(slo.id, { indicator: newIndicator });
expectDeletionOfObsoleteSLOData(slo);
expect(mockRepository.save).toBeCalledWith(
expect.objectContaining({
...slo,
indicator: newIndicator,
revision: 2,
updatedAt: expect.anything(),
})
);
expectInstallationOfNewSLOTransform();
});
expectDeletionOfObsoleteSLOData(slo);
expectInstallationOfNewSLOTransform();
});
function expectTransformManagerNeverCalled() {
expect(mockTransformManager.stop).not.toBeCalled();
expect(mockTransformManager.uninstall).not.toBeCalled();
expect(mockTransformManager.start).not.toBeCalled();
expect(mockTransformManager.install).not.toBeCalled();
}
it('updates the timeslice target correctly', async () => {
const slo = createSLOWithTimeslicesBudgetingMethod();
mockRepository.findById.mockResolvedValueOnce(slo);
await updateSLO.execute(slo.id, {
objective: {
target: slo.objective.target,
timesliceTarget: 0.1,
timesliceWindow: slo.objective.timesliceWindow,
},
});
expectDeletionOfObsoleteSLOData(slo);
expectInstallationOfNewSLOTransform();
});
it('consideres a timeslice window change as a breaking change', async () => {
const slo = createSLOWithTimeslicesBudgetingMethod();
mockRepository.findById.mockResolvedValueOnce(slo);
await updateSLO.execute(slo.id, {
objective: {
target: slo.objective.target,
timesliceTarget: slo.objective.timesliceTarget,
timesliceWindow: fiveMinute(),
},
});
expectDeletionOfObsoleteSLOData(slo);
expectInstallationOfNewSLOTransform();
});
it('index a temporary summary document', async () => {
const slo = createSLO({
id: 'unique-id',
indicator: createAPMTransactionErrorRateIndicator({ environment: 'development' }),
});
mockRepository.findById.mockResolvedValueOnce(slo);
const newIndicator = createAPMTransactionErrorRateIndicator({ environment: 'production' });
await updateSLO.execute(slo.id, { indicator: newIndicator });
expect(mockEsClient.index.mock.calls[0]).toMatchSnapshot();
});
it('removes the obsolete data from the SLO previous revision', async () => {
const slo = createSLO({
indicator: createAPMTransactionErrorRateIndicator({ environment: 'development' }),
});
mockRepository.findById.mockResolvedValueOnce(slo);
const newIndicator = createAPMTransactionErrorRateIndicator({ environment: 'production' });
await updateSLO.execute(slo.id, { indicator: newIndicator });
expectDeletionOfObsoleteSLOData(slo);
expect(mockRepository.save).toBeCalledWith(
expect.objectContaining({
...slo,
indicator: newIndicator,
revision: 2,
updatedAt: expect.anything(),
})
);
expectInstallationOfNewSLOTransform();
});
function expectInstallationOfNewSLOTransform() {
expect(mockTransformManager.start).toBeCalled();
@ -169,8 +149,26 @@ describe('UpdateSLO', () => {
const transformId = getSLOTransformId(originalSlo.id, originalSlo.revision);
expect(mockTransformManager.stop).toBeCalledWith(transformId);
expect(mockTransformManager.uninstall).toBeCalledWith(transformId);
expect(mockEsClient.deleteByQuery).toBeCalledWith(
expect(mockEsClient.deleteByQuery).toHaveBeenCalledTimes(2);
expect(mockEsClient.deleteByQuery).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
index: SLO_DESTINATION_INDEX_PATTERN,
query: {
bool: {
filter: [
{ term: { 'slo.id': originalSlo.id } },
{ term: { 'slo.revision': originalSlo.revision } },
],
},
},
})
);
expect(mockEsClient.deleteByQuery).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
index: SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
query: {
bool: {
filter: [

Some files were not shown because too many files have changed in this diff Show more