Skip to content

Commit

Permalink
[Logs UI] [Alerting] "Group by" functionality (#68250)
Browse files Browse the repository at this point in the history
- Add "group by" functionality to logs alerts
  • Loading branch information
Kerry350 authored Jun 30, 2020
1 parent a40e58e commit ceb8595
Show file tree
Hide file tree
Showing 10 changed files with 918 additions and 246 deletions.
103 changes: 87 additions & 16 deletions x-pack/plugins/infra/common/alerting/logs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import * as rt from 'io-ts';
import { commonSearchSuccessResponseFieldsRT } from '../../utils/elasticsearch_runtime_types';

export const LOG_DOCUMENT_COUNT_ALERT_TYPE_ID = 'logs.alert.document.count';

Expand All @@ -20,6 +22,19 @@ export enum Comparator {
NOT_MATCH_PHRASE = 'does not match phrase',
}

const ComparatorRT = rt.keyof({
[Comparator.GT]: null,
[Comparator.GT_OR_EQ]: null,
[Comparator.LT]: null,
[Comparator.LT_OR_EQ]: null,
[Comparator.EQ]: null,
[Comparator.NOT_EQ]: null,
[Comparator.MATCH]: null,
[Comparator.NOT_MATCH]: null,
[Comparator.MATCH_PHRASE]: null,
[Comparator.NOT_MATCH_PHRASE]: null,
});

// Maps our comparators to i18n strings, some comparators have more specific wording
// depending on the field type the comparator is being used with.
export const ComparatorToi18nMap = {
Expand Down Expand Up @@ -74,22 +89,78 @@ export enum AlertStates {
ERROR,
}

export interface DocumentCount {
comparator: Comparator;
value: number;
}
const DocumentCountRT = rt.type({
comparator: ComparatorRT,
value: rt.number,
});

export interface Criterion {
field: string;
comparator: Comparator;
value: string | number;
}
export type DocumentCount = rt.TypeOf<typeof DocumentCountRT>;

export interface LogDocumentCountAlertParams {
count: DocumentCount;
criteria: Criterion[];
timeUnit: 's' | 'm' | 'h' | 'd';
timeSize: number;
}
const CriterionRT = rt.type({
field: rt.string,
comparator: ComparatorRT,
value: rt.union([rt.string, rt.number]),
});

export type Criterion = rt.TypeOf<typeof CriterionRT>;

const TimeUnitRT = rt.union([rt.literal('s'), rt.literal('m'), rt.literal('h'), rt.literal('d')]);
export type TimeUnit = rt.TypeOf<typeof TimeUnitRT>;

export const LogDocumentCountAlertParamsRT = rt.intersection([
rt.type({
count: DocumentCountRT,
criteria: rt.array(CriterionRT),
timeUnit: TimeUnitRT,
timeSize: rt.number,
}),
rt.partial({
groupBy: rt.array(rt.string),
}),
]);

export type LogDocumentCountAlertParams = rt.TypeOf<typeof LogDocumentCountAlertParamsRT>;

export const UngroupedSearchQueryResponseRT = rt.intersection([
commonSearchSuccessResponseFieldsRT,
rt.type({
hits: rt.type({
total: rt.type({
value: rt.number,
}),
}),
}),
]);

export type UngroupedSearchQueryResponse = rt.TypeOf<typeof UngroupedSearchQueryResponseRT>;

export const GroupedSearchQueryResponseRT = rt.intersection([
commonSearchSuccessResponseFieldsRT,
rt.type({
aggregations: rt.type({
groups: rt.intersection([
rt.type({
buckets: rt.array(
rt.type({
key: rt.record(rt.string, rt.string),
doc_count: rt.number,
filtered_results: rt.type({
doc_count: rt.number,
}),
})
),
}),
rt.partial({
after_key: rt.record(rt.string, rt.string),
}),
]),
}),
hits: rt.type({
total: rt.type({
value: rt.number,
}),
}),
}),
]);

export type TimeUnit = 's' | 'm' | 'h' | 'd';
export type GroupedSearchQueryResponse = rt.TypeOf<typeof GroupedSearchQueryResponseRT>;
18 changes: 18 additions & 0 deletions x-pack/plugins/infra/common/utils/elasticsearch_runtime_types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import * as rt from 'io-ts';

export const commonSearchSuccessResponseFieldsRT = rt.type({
_shards: rt.type({
total: rt.number,
successful: rt.number,
skipped: rt.number,
failed: rt.number,
}),
timed_out: rt.boolean,
took: rt.number,
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { DocumentCount } from './document_count';
import { Criteria } from './criteria';
import { useSourceId } from '../../../../containers/source_id';
import { LogSourceProvider, useLogSourceContext } from '../../../../containers/logs/log_source';
import { GroupByExpression } from '../../shared/group_by_expression/group_by_expression';

export interface ExpressionCriteria {
field?: string;
Expand Down Expand Up @@ -121,7 +122,6 @@ export const Editor: React.FC<Props> = (props) => {
const { setAlertParams, alertParams, errors } = props;
const [hasSetDefaults, setHasSetDefaults] = useState<boolean>(false);
const { sourceStatus } = useLogSourceContext();

useMount(() => {
for (const [key, value] of Object.entries({ ...DEFAULT_EXPRESSION, ...alertParams })) {
setAlertParams(key, value);
Expand All @@ -140,6 +140,17 @@ export const Editor: React.FC<Props> = (props) => {
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [sourceStatus]);

const groupByFields = useMemo(() => {
if (sourceStatus?.logIndexFields) {
return sourceStatus.logIndexFields.filter((field) => {
return field.type === 'string' && field.aggregatable;
});
} else {
return [];
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [sourceStatus]);

const updateCount = useCallback(
(countParams) => {
const nextCountParams = { ...alertParams.count, ...countParams };
Expand Down Expand Up @@ -172,6 +183,13 @@ export const Editor: React.FC<Props> = (props) => {
[setAlertParams]
);

const updateGroupBy = useCallback(
(groups: string[]) => {
setAlertParams('groupBy', groups);
},
[setAlertParams]
);

const addCriterion = useCallback(() => {
const nextCriteria = alertParams?.criteria
? [...alertParams.criteria, DEFAULT_CRITERIA]
Expand Down Expand Up @@ -219,6 +237,12 @@ export const Editor: React.FC<Props> = (props) => {
errors={errors as { [key: string]: string[] }}
/>

<GroupByExpression
selectedGroups={alertParams.groupBy}
onChange={updateGroupBy}
fields={groupByFields}
/>

<div>
<EuiButtonEmpty
color={'primary'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function getAlertType(): AlertTypeModel {
defaultActionMessage: i18n.translate(
'xpack.infra.logs.alerting.threshold.defaultActionMessage',
{
defaultMessage: `\\{\\{context.matchingDocuments\\}\\} log entries have matched the following conditions: \\{\\{context.conditions\\}\\}`,
defaultMessage: `\\{\\{#context.group\\}\\}\\{\\{context.group\\}\\} - \\{\\{/context.group\\}\\}\\{\\{context.matchingDocuments\\}\\} log entries have matched the following conditions: \\{\\{context.conditions\\}\\}`,
}
),
requiresAppContext: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import React, { useState, useMemo } from 'react';
import { IFieldType } from 'src/plugins/data/public';
import { i18n } from '@kbn/i18n';
import {
EuiPopoverTitle,
EuiFlexItem,
EuiFlexGroup,
EuiPopover,
EuiExpression,
} from '@elastic/eui';
import { GroupBySelector } from './selector';

interface Props {
selectedGroups?: string[];
fields: IFieldType[];
onChange: (groupBy: string[]) => void;
label?: string;
}

const DEFAULT_GROUP_BY_LABEL = i18n.translate('xpack.infra.alerting.alertFlyout.groupByLabel', {
defaultMessage: 'Group By',
});

const EVERYTHING_PLACEHOLDER = i18n.translate(
'xpack.infra.alerting.alertFlyout.groupBy.placeholder',
{
defaultMessage: 'Nothing (ungrouped)',
}
);

export const GroupByExpression: React.FC<Props> = ({
selectedGroups = [],
fields,
label,
onChange,
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);

const expressionValue = useMemo(() => {
return selectedGroups.length > 0 ? selectedGroups.join(', ') : EVERYTHING_PLACEHOLDER;
}, [selectedGroups]);

const labelProp = label ?? DEFAULT_GROUP_BY_LABEL;

return (
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiPopover
id="groupByExpression"
button={
<EuiExpression
description={labelProp}
uppercase={true}
value={expressionValue}
isActive={isPopoverOpen}
onClick={() => setIsPopoverOpen(true)}
/>
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
ownFocus
panelPaddingSize="s"
anchorPosition="downLeft"
>
<div style={{ zIndex: 11000 }}>
<EuiPopoverTitle>{labelProp}</EuiPopoverTitle>
<GroupBySelector
selectedGroups={selectedGroups}
onChange={onChange}
fields={fields}
label={labelProp}
placeholder={EVERYTHING_PLACEHOLDER}
/>
</div>
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { EuiComboBox } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { IFieldType } from 'src/plugins/data/public';

interface Props {
selectedGroups?: string[];
onChange: (groupBy: string[]) => void;
fields: IFieldType[];
label: string;
placeholder: string;
}

export const GroupBySelector = ({
onChange,
fields,
selectedGroups = [],
label,
placeholder,
}: Props) => {
const handleChange = useCallback(
(selectedOptions: Array<{ label: string }>) => {
const groupBy = selectedOptions.map((option) => option.label);
onChange(groupBy);
},
[onChange]
);

const formattedSelectedGroups = useMemo(() => {
return selectedGroups.map((group) => ({ label: group }));
}, [selectedGroups]);

const options = useMemo(() => {
return fields.filter((field) => field.aggregatable).map((field) => ({ label: field.name }));
}, [fields]);

return (
<div style={{ minWidth: '300px' }}>
<EuiComboBox
placeholder={placeholder}
aria-label={label}
fullWidth
singleSelection={false}
selectedOptions={formattedSelectedGroups}
options={options}
onChange={handleChange}
isClearable={true}
/>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export interface InfraDatabaseSearchResponse<Hit = {}, Aggregations = undefined>
skipped: number;
failed: number;
};
timed_out: boolean;
aggregations?: Aggregations;
hits: {
total: {
Expand Down
Loading

0 comments on commit ceb8595

Please sign in to comment.