[SLOs] synthetics availability - add cardinality count for group by (#178454)

## Summary

Adds group by cardinality count to the synthetics availability SLO
indicator

Resolves https://github.com/elastic/kibana/issues/178409
Resolves https://github.com/elastic/kibana/issues/178140

Also, it's come to my attention that
https://github.com/elastic/kibana/issues/178341 was not fixed by a
previous PR. This PR now also resolves
https://github.com/elastic/kibana/issues/178341

### Testing

1. Create an cluster with oblt-cli and add the config to your
`kibana.dev.yml`
2. Navigate to the Synthetics app. Create at least two synthetic
monitors
3. Navigate to SLO create. Select the synthetic availability indicator
4. Check the group by cardinality callout. The cardinality should
reflect the number of monitor/location combinations
<img width="730" alt="Screenshot 2024-04-12 at 1 04 57 PM"
src="a05ffaff-c01b-4107-8f8d-2ea8362fe72e">
5. Now filter by monitor name or tag. The group by cardinality should
reflect the number of monitors that match the filters
<img width="733" alt="Screenshot 2024-04-12 at 1 05 11 PM"
src="079c74ea-dd1c-45f2-bf0e-2dbefea30f96">

### Testing https://github.com/elastic/kibana/issues/178341
To test the fix for https://github.com/elastic/kibana/issues/178341,
create a simple custom kql SLO with a group by. Add a overall filter
that would impact the overall group by count. Verify that the group by
count accurately reflects the overall filter.
This commit is contained in:
Dominique Clarke 2024-04-12 21:40:30 -04:00 committed by GitHub
parent b67ab78a65
commit 672bb5a54b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 618 additions and 38 deletions

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ALL_VALUE, QuerySchema } from '@kbn/slo-schema';
import { i18n } from '@kbn/i18n';
import { EuiCallOut, EuiLoadingSpinner } from '@elastic/eui';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { useFetchGroupByCardinality } from '../../../../hooks/use_fetch_group_by_cardinality';
import { CreateSLOForm } from '../../types';
import { getGroupKeysProse } from '../../../../utils/slo/groupings';
export function GroupByCardinality({
titleAppend,
customFilters,
}: {
titleAppend?: React.ReactNode;
customFilters?: QuerySchema;
}) {
const { watch } = useFormContext<CreateSLOForm>();
const index = watch('indicator.params.index');
const filters = watch('indicator.params.filter');
const timestampField = watch('indicator.params.timestampField');
const groupByField = watch('groupBy');
const { isLoading: isGroupByCardinalityLoading, data: groupByCardinality } =
useFetchGroupByCardinality(index, timestampField, groupByField, customFilters || filters);
const groupBy = [groupByField].flat().filter((value) => !!value);
const hasGroupBy = !groupBy.includes(ALL_VALUE) && groupBy.length;
if (!hasGroupBy) {
return null;
}
if (isGroupByCardinalityLoading && !groupByCardinality) {
return <EuiCallOut size="s" title={<EuiLoadingSpinner />} />;
}
if (!groupByCardinality) {
return null;
}
const cardinalityMessage = i18n.translate('xpack.slo.sloEdit.groupBy.cardinalityInfo', {
defaultMessage:
'Selected group by field {groupBy} will generate at least {card} SLO instances based on the last 24h sample data.',
values: {
card: groupByCardinality.cardinality,
groupBy: getGroupKeysProse(groupByField),
},
});
return (
<EuiCallOut
size="s"
iconType={groupByCardinality.isHighCardinality ? 'warning' : ''}
color={groupByCardinality.isHighCardinality ? 'warning' : 'primary'}
title={
titleAppend ? (
<>
{titleAppend} {cardinalityMessage}
</>
) : (
cardinalityMessage
)
}
/>
);
}

View file

@ -0,0 +1,73 @@
/*
* 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 { canGroupBy } from './group_by_field';
describe('canGroupBy', () => {
it('handles multi fields where there are multi es types', () => {
const field = {
name: 'event.action.keyword',
type: 'string',
esTypes: ['keyword'],
searchable: true,
aggregatable: true,
readFromDocValues: true,
metadata_field: false,
subType: {
multi: {
parent: 'event.action',
},
},
isMapped: true,
shortDotsEnable: false,
};
expect(canGroupBy(field)).toBe(true);
const field2 = {
name: 'event.action',
type: 'string',
esTypes: ['keyword', 'text'],
searchable: true,
aggregatable: true,
readFromDocValues: true,
metadata_field: false,
isMapped: true,
shortDotsEnable: false,
};
expect(canGroupBy(field2)).toBe(false);
});
it('handles date fields', () => {
const field = {
name: '@timestamp',
type: 'date',
esTypes: ['date'],
searchable: true,
aggregatable: true,
readFromDocValues: true,
metadata_field: false,
isMapped: true,
shortDotsEnable: false,
};
expect(canGroupBy(field)).toBe(false);
});
it('handles non aggregatable fields', () => {
const field = {
name: 'event.action',
type: 'string',
esTypes: ['text'],
searchable: true,
aggregatable: false,
readFromDocValues: true,
metadata_field: false,
isMapped: true,
shortDotsEnable: false,
};
expect(canGroupBy(field)).toBe(false);
});
});

View file

@ -7,29 +7,20 @@
import { ALL_VALUE } from '@kbn/slo-schema';
import { i18n } from '@kbn/i18n';
import { EuiCallOut, EuiIconTip } from '@elastic/eui';
import { EuiIconTip } from '@elastic/eui';
import React from 'react';
import { DataView } from '@kbn/data-views-plugin/common';
import { DataView, FieldSpec } from '@kbn/data-views-plugin/common';
import { useFormContext } from 'react-hook-form';
import { OptionalText } from './optional_text';
import { useFetchGroupByCardinality } from '../../../../hooks/use_fetch_group_by_cardinality';
import { CreateSLOForm } from '../../types';
import { IndexFieldSelector } from './index_field_selector';
import { getGroupKeysProse } from '../../../../utils/slo/groupings';
import { GroupByCardinality } from './group_by_cardinality';
export function GroupByField({ dataView, isLoading }: { dataView?: DataView; isLoading: boolean }) {
const { watch } = useFormContext<CreateSLOForm>();
const groupByFields =
dataView?.fields?.filter((field) => field.aggregatable && field.type !== 'date') ?? [];
const groupByFields = dataView?.fields?.filter((field) => canGroupBy(field)) ?? [];
const index = watch('indicator.params.index');
const timestampField = watch('indicator.params.timestampField');
const groupByField = watch('groupBy');
const { isLoading: isGroupByCardinalityLoading, data: groupByCardinality } =
useFetchGroupByCardinality(index, timestampField, groupByField);
const groupBy = [groupByField].flat().filter((value) => !!value);
const hasGroupBy = !groupBy.includes(ALL_VALUE) && groupBy.length;
return (
<>
@ -57,21 +48,17 @@ export function GroupByField({ dataView, isLoading }: { dataView?: DataView; isL
isLoading={!!index && isLoading}
isDisabled={!index}
/>
{!isGroupByCardinalityLoading && !!groupByCardinality && hasGroupBy && (
<EuiCallOut
size="s"
iconType={groupByCardinality.isHighCardinality ? 'warning' : ''}
color={groupByCardinality.isHighCardinality ? 'warning' : 'primary'}
title={i18n.translate('xpack.slo.sloEdit.groupBy.cardinalityInfo', {
defaultMessage:
'Selected group by field {groupBy} will generate at least {card} SLO instances based on the last 24h sample data.',
values: {
card: groupByCardinality.cardinality,
groupBy: getGroupKeysProse(groupByField),
},
})}
/>
)}
<GroupByCardinality />
</>
);
}
export const canGroupBy = (field: FieldSpec) => {
const isAggregatable = field.aggregatable;
const isNotDate = field.type !== 'date';
// handles multi fields where there are multi es types, which could include 'text'
// text fields break the transforms so we must ensure that the field is only a keyword
const isOnlyKeyword = field.esTypes?.length === 1 && field.esTypes[0] === 'keyword';
return isAggregatable && isNotDate && isOnlyKeyword;
};

View file

@ -186,7 +186,7 @@ export function SloEditFormObjectiveSection() {
<p>
<FormattedMessage
id="xpack.slo.sloEdit.sliType.syntheticAvailability.objectiveMessage"
defaultMessage="The Synthetics availability indicator requires the budgeting method to be set to 'Occurances'."
defaultMessage="The Synthetics availability indicator requires the budgeting method to be set to 'Occurrences'."
/>
</p>
</EuiCallOut>

View file

@ -0,0 +1,331 @@
/*
* 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 {
formatAllFilters,
getGroupByCardinalityFilters,
} from './synthetics_availability_indicator_type_form';
describe('get group by cardinality filters', () => {
it('formats filters correctly', () => {
const monitorIds = ['1234'];
const tags = ['tag1', 'tag2'];
const projects = ['project1', 'project2'];
expect(getGroupByCardinalityFilters(monitorIds, projects, tags)).toEqual([
{
$state: { store: 'appState' },
meta: {
alias: null,
disabled: false,
key: 'monitor.id',
negate: false,
params: ['1234'],
type: 'phrases',
},
query: {
bool: { minimum_should_match: 1, should: [{ match_phrase: { 'monitor.id': '1234' } }] },
},
},
{
$state: { store: 'appState' },
meta: {
alias: null,
disabled: false,
key: 'monitor.project.id',
negate: false,
params: ['project1', 'project2'],
type: 'phrases',
},
query: {
bool: {
minimum_should_match: 1,
should: [
{ match_phrase: { 'monitor.project.id': 'project1' } },
{ match_phrase: { 'monitor.project.id': 'project2' } },
],
},
},
},
{
$state: { store: 'appState' },
meta: {
alias: null,
disabled: false,
key: 'tags',
negate: false,
params: ['tag1', 'tag2'],
type: 'phrases',
},
query: {
bool: {
minimum_should_match: 1,
should: [{ match_phrase: { tags: 'tag1' } }, { match_phrase: { tags: 'tag2' } }],
},
},
},
]);
});
it('does not include filters when arrays are empty', () => {
// @ts-ignore
const monitorIds = [];
// @ts-ignore
const tags = [];
// @ts-ignore
const projects = [];
// @ts-ignore
expect(getGroupByCardinalityFilters(monitorIds, projects, tags)).toEqual([]);
});
});
describe('formatAllFilters', () => {
const monitorIds = ['1234'];
const tags = ['tag1', 'tag2'];
const projects = ['project1', 'project2'];
const cardinalityFilters = getGroupByCardinalityFilters(monitorIds, projects, tags);
it('handles global kql filter', () => {
const kqlFilter = 'monitor.id: "1234"';
expect(formatAllFilters(kqlFilter, cardinalityFilters)).toEqual({
filters: [
{
$state: {
store: 'appState',
},
meta: {
alias: null,
disabled: false,
key: 'monitor.id',
negate: false,
params: ['1234'],
type: 'phrases',
},
query: {
bool: {
minimum_should_match: 1,
should: [
{
match_phrase: {
'monitor.id': '1234',
},
},
],
},
},
},
{
$state: {
store: 'appState',
},
meta: {
alias: null,
disabled: false,
key: 'monitor.project.id',
negate: false,
params: ['project1', 'project2'],
type: 'phrases',
},
query: {
bool: {
minimum_should_match: 1,
should: [
{
match_phrase: {
'monitor.project.id': 'project1',
},
},
{
match_phrase: {
'monitor.project.id': 'project2',
},
},
],
},
},
},
{
$state: {
store: 'appState',
},
meta: {
alias: null,
disabled: false,
key: 'tags',
negate: false,
params: ['tag1', 'tag2'],
type: 'phrases',
},
query: {
bool: {
minimum_should_match: 1,
should: [
{
match_phrase: {
tags: 'tag1',
},
},
{
match_phrase: {
tags: 'tag2',
},
},
],
},
},
},
],
kqlQuery: 'monitor.id: "1234"',
});
});
it('handles global filters meta ', () => {
const globalFilters = {
filters: [
{
$state: {
store: 'appState',
},
meta: {
alias: null,
disabled: false,
key: 'monitor.name',
negate: false,
params: ['test name'],
type: 'phrases',
},
query: {
bool: {
minimum_should_match: 1,
should: [
{
match_phrase: {
'monitor.name': 'test name',
},
},
],
},
},
},
],
kqlQuery: 'monitor.id: "1234"',
};
expect(formatAllFilters(globalFilters, cardinalityFilters)).toEqual({
filters: [
{
$state: {
store: 'appState',
},
meta: {
alias: null,
disabled: false,
key: 'monitor.name',
negate: false,
params: ['test name'],
type: 'phrases',
},
query: {
bool: {
minimum_should_match: 1,
should: [
{
match_phrase: {
'monitor.name': 'test name',
},
},
],
},
},
},
{
$state: {
store: 'appState',
},
meta: {
alias: null,
disabled: false,
key: 'monitor.id',
negate: false,
params: ['1234'],
type: 'phrases',
},
query: {
bool: {
minimum_should_match: 1,
should: [
{
match_phrase: {
'monitor.id': '1234',
},
},
],
},
},
},
{
$state: {
store: 'appState',
},
meta: {
alias: null,
disabled: false,
key: 'monitor.project.id',
negate: false,
params: ['project1', 'project2'],
type: 'phrases',
},
query: {
bool: {
minimum_should_match: 1,
should: [
{
match_phrase: {
'monitor.project.id': 'project1',
},
},
{
match_phrase: {
'monitor.project.id': 'project2',
},
},
],
},
},
},
{
$state: {
store: 'appState',
},
meta: {
alias: null,
disabled: false,
key: 'tags',
negate: false,
params: ['tag1', 'tag2'],
type: 'phrases',
},
query: {
bool: {
minimum_should_match: 1,
should: [
{
match_phrase: {
tags: 'tag1',
},
},
{
match_phrase: {
tags: 'tag2',
},
},
],
},
},
},
],
kqlQuery: 'monitor.id: "1234"',
});
});
});

View file

@ -5,25 +5,34 @@
* 2.0.
*/
import React, { useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiCallOut } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ALL_VALUE, SyntheticsAvailabilityIndicator } from '@kbn/slo-schema';
import {
ALL_VALUE,
SyntheticsAvailabilityIndicator,
QuerySchema,
kqlQuerySchema,
kqlWithFiltersSchema,
} from '@kbn/slo-schema';
import { Filter, FilterStateStore } from '@kbn/es-query';
import { useFormContext } from 'react-hook-form';
import { FieldSelector } from '../synthetics_common/field_selector';
import { CreateSLOForm } from '../../types';
import { DataPreviewChart } from '../common/data_preview_chart';
import { QueryBuilder } from '../common/query_builder';
import { GroupByCardinality } from '../common/group_by_cardinality';
const ONE_DAY_IN_MILLISECONDS = 1 * 60 * 60 * 1000 * 24;
export function SyntheticsAvailabilityIndicatorTypeForm() {
const { watch } = useFormContext<CreateSLOForm<SyntheticsAvailabilityIndicator>>();
const [monitorIds = [], projects = [], tags = [], index] = watch([
const [monitorIds = [], projects = [], tags = [], index, globalFilters] = watch([
'indicator.params.monitorIds',
'indicator.params.projects',
'indicator.params.tags',
'indicator.params.index',
'indicator.params.filter',
]);
const [range, _] = useState({
@ -36,6 +45,12 @@ export function SyntheticsAvailabilityIndicatorTypeForm() {
projects: projects.map((project) => project.value).filter((id) => id !== ALL_VALUE),
tags: tags.map((tag) => tag.value).filter((id) => id !== ALL_VALUE),
};
const groupByCardinalityFilters: Filter[] = getGroupByCardinalityFilters(
filters.monitorIds,
filters.projects,
filters.tags
);
const allFilters = formatAllFilters(globalFilters, groupByCardinalityFilters);
return (
<EuiFlexGroup direction="column" gutterSize="l">
@ -121,11 +136,12 @@ export function SyntheticsAvailabilityIndicatorTypeForm() {
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiCallOut
size="s"
title="Synthetics availability SLIs are automatically grouped by monitor and location"
iconType="iInCircle"
<GroupByCardinality
titleAppend={i18n.translate('xpack.slo.sloEdit.syntheticsAvailability.warning', {
defaultMessage:
'Synthetics availability SLIs are automatically grouped by monitor and location.',
})}
customFilters={allFilters as QuerySchema}
/>
<DataPreviewChart range={range} label={LABEL} useGoodBadEventsChart />
</EuiFlexGroup>
@ -135,3 +151,103 @@ export function SyntheticsAvailabilityIndicatorTypeForm() {
const LABEL = i18n.translate('xpack.slo.sloEdit.dataPreviewChart.syntheticsAvailability.xTitle', {
defaultMessage: 'Last 24 hours',
});
export const getGroupByCardinalityFilters = (
monitorIds: string[],
projects: string[],
tags: string[]
): Filter[] => {
const monitorIdFilters = monitorIds.length
? {
meta: {
disabled: false,
negate: false,
alias: null,
key: 'monitor.id',
params: monitorIds,
type: 'phrases',
},
$state: {
store: FilterStateStore.APP_STATE,
},
query: {
bool: {
minimum_should_match: 1,
should: monitorIds.map((id) => ({
match_phrase: {
'monitor.id': id,
},
})),
},
},
}
: null;
const projectFilters = projects.length
? {
meta: {
disabled: false,
negate: false,
alias: null,
key: 'monitor.project.id',
params: projects,
type: 'phrases',
},
$state: {
store: FilterStateStore.APP_STATE,
},
query: {
bool: {
minimum_should_match: 1,
should: projects.map((id) => ({
match_phrase: {
'monitor.project.id': id,
},
})),
},
},
}
: null;
const tagFilters = tags.length
? {
meta: {
disabled: false,
negate: false,
alias: null,
key: 'tags',
params: tags,
type: 'phrases',
},
$state: {
store: FilterStateStore.APP_STATE,
},
query: {
bool: {
minimum_should_match: 1,
should: tags.map((tag) => ({
match_phrase: {
tags: tag,
},
})),
},
},
}
: null;
return [monitorIdFilters, projectFilters, tagFilters].filter((value) => !!value) as Filter[];
};
export const formatAllFilters = (
globalFilters: QuerySchema = '',
groupByCardinalityFilters: Filter[]
) => {
if (kqlQuerySchema.is(globalFilters)) {
return { kqlQuery: globalFilters, filters: groupByCardinalityFilters };
} else if (kqlWithFiltersSchema) {
return {
kqlQuery: globalFilters.kqlQuery,
filters: [...globalFilters.filters, ...groupByCardinalityFilters],
};
}
};

View file

@ -167,7 +167,7 @@ export class SyntheticsAvailabilityTransformGenerator extends TransformGenerator
private buildAggregations(slo: SLO) {
if (!occurrencesBudgetingMethodSchema.is(slo.budgetingMethod)) {
throw new Error(
'The sli.synthetics.availability indicator MUST have an occurances budgeting method.'
"The sli.synthetics.availability indicator MUST have an 'Occurrences' budgeting method."
);
}