[Infra] Limit the number of metrics accepted by Metrics Explorer API (#188112)

part of [3628](https://github.com/elastic/observability-dev/issues/3628)
- private

## Summary

After adding 20 items, users can no longer add more fields and will see
the message below


<img width="1725" alt="image"
src="fd504212-0e0f-485d-a8fe-b991c829950e">


### Extra

- There was an unused and duplicate `metrics_explorer` route in infra. I
removed it. It should've been removed when the `metrics_data_access`
plugin was created.
- Cleaned up `constants` field in `metrics_data_access` and `infra`
plugins

### How to test

- Start a local Kibana instance pointing to an oblt cluster
- Navigate to Infrastructure > Metrics Explorer
- Try to select more than 20 fields in the metrics field

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Carlos Crespo 2024-07-16 15:44:05 +02:00 committed by GitHub
parent d71d48797d
commit 5ec5b994dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 234 additions and 835 deletions

View file

@ -12,7 +12,7 @@ import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common/parse_te
import type { InventoryItemType } from '@kbn/metrics-data-access-plugin/common';
import {
fifteenMinutesInMilliseconds,
HOST_FIELD,
HOST_NAME_FIELD,
LINK_TO_INVENTORY,
METRICS_EXPLORER_URL,
} from '../../constants';
@ -54,7 +54,7 @@ export const getInventoryViewInAppUrl = (
const nodeTypeField = `${ALERT_RULE_PARAMETERS}.nodeType`;
const nodeType = inventoryFields[nodeTypeField] as InventoryItemType;
const hostName = inventoryFields[HOST_FIELD];
const hostName = inventoryFields[HOST_NAME_FIELD];
if (nodeType) {
if (hostName) {
@ -95,7 +95,7 @@ export const getInventoryViewInAppUrl = (
};
export const getMetricsViewInAppUrl = (fields: ParsedTechnicalFields & Record<string, any>) => {
const hostName = fields[HOST_FIELD];
const hostName = fields[HOST_NAME_FIELD];
const timestamp = fields[TIMESTAMP];
return hostName ? getLinkToHostDetails({ hostName, timestamp }) : METRICS_EXPLORER_URL;

View file

@ -17,13 +17,16 @@ export const LOGS_FEATURE_ID = 'logs';
export type InfraFeatureId = typeof METRICS_FEATURE_ID | typeof LOGS_FEATURE_ID;
export const TIMESTAMP_FIELD = '@timestamp';
export const MESSAGE_FIELD = 'message';
export const TIEBREAKER_FIELD = '_doc';
export const HOST_FIELD = 'host.name';
export const CONTAINER_FIELD = 'container.id';
export const POD_FIELD = 'kubernetes.pod.uid';
export const CMDLINE_FIELD = 'system.process.cmdline';
// system
export const HOST_NAME_FIELD = 'host.name';
export const CONTAINER_ID_FIELD = 'container.id';
export const KUBERNETES_POD_UID_FIELD = 'kubernetes.pod.uid';
export const SYSTEM_PROCESS_CMDLINE_FIELD = 'system.process.cmdline';
// logs
export const MESSAGE_FIELD = 'message';
export const O11Y_AAD_FIELDS = [
'cloud.*',

View file

@ -6,12 +6,12 @@
*/
import { useState } from 'react';
import { HOST_FIELD } from '../../../../common/constants';
import { HOST_NAME_FIELD } from '../../../../common/constants';
import { useAssetDetailsRenderPropsContext } from './use_asset_details_render_props';
import { useAssetDetailsUrlState } from './use_asset_details_url_state';
function buildFullProfilingKuery(assetName: string, profilingSearch?: string) {
const defaultKuery = `${HOST_FIELD} : "${assetName}"`;
const defaultKuery = `${HOST_NAME_FIELD} : "${assetName}"`;
const customKuery = profilingSearch?.trim() ?? '';
return customKuery !== '' ? `${defaultKuery} and ${customKuery}` : defaultKuery;

View file

@ -13,7 +13,7 @@ import { useLinkProps } from '@kbn/observability-shared-plugin/public';
import { Section } from '../../components/section';
import { ServicesSectionTitle } from './section_titles';
import { useServices } from '../../hooks/use_services';
import { HOST_FIELD } from '../../../../../common/constants';
import { HOST_NAME_FIELD } from '../../../../../common/constants';
import { LinkToApmServices } from '../../links';
import { APM_HOST_FILTER_FIELD } from '../../constants';
import { LinkToApmService } from '../../links/link_to_apm_service';
@ -37,7 +37,7 @@ export const ServicesContent = ({
});
const params = useMemo(
() => ({
filters: { [HOST_FIELD]: hostName },
filters: { [HOST_NAME_FIELD]: hostName },
from: dateRange.from,
to: dateRange.to,
}),

View file

@ -11,7 +11,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui';
import { FlamegraphLocator } from '@kbn/observability-shared-plugin/public/locators/profiling/flamegraph_locator';
import { TopNFunctionsLocator } from '@kbn/observability-shared-plugin/public/locators/profiling/topn_functions_locator';
import { StacktracesLocator } from '@kbn/observability-shared-plugin/public/locators/profiling/stacktraces_locator';
import { HOST_FIELD } from '../../../../../common/constants';
import { HOST_NAME_FIELD } from '../../../../../common/constants';
const PROFILING_FEEDBACK_URL = 'https://ela.st/profiling-feedback';
@ -31,7 +31,7 @@ export function ProfilingLinks({
profilingLinkLabel,
}: Props) {
const profilingLinkURL = profilingLinkLocator.getRedirectUrl({
kuery: `${HOST_FIELD}:"${hostname}"`,
kuery: `${HOST_NAME_FIELD}:"${hostname}"`,
rangeFrom: from,
rangeTo: to,
});

View file

@ -28,7 +28,11 @@ import {
} from '../hooks/use_metrics_explorer_options';
import { createTSVBLink, TSVB_WORKAROUND_INDEX_PATTERN } from './helpers/create_tsvb_link';
import { useNodeDetailsRedirect } from '../../../link_to';
import { HOST_FIELD, POD_FIELD, CONTAINER_FIELD } from '../../../../../common/constants';
import {
HOST_NAME_FIELD,
KUBERNETES_POD_UID_FIELD,
CONTAINER_ID_FIELD,
} from '../../../../../common/constants';
export interface Props {
options: MetricsExplorerOptions;
@ -41,13 +45,13 @@ export interface Props {
const fieldToNodeType = (groupBy: string | string[]): InventoryItemType | undefined => {
const fields = Array.isArray(groupBy) ? groupBy : [groupBy];
if (fields.includes(HOST_FIELD)) {
if (fields.includes(HOST_NAME_FIELD)) {
return 'host';
}
if (fields.includes(POD_FIELD)) {
if (fields.includes(KUBERNETES_POD_UID_FIELD)) {
return 'pod';
}
if (fields.includes(CONTAINER_FIELD)) {
if (fields.includes(CONTAINER_ID_FIELD)) {
return 'container';
}
};

View file

@ -5,9 +5,17 @@
* 2.0.
*/
import { EuiComboBox } from '@elastic/eui';
import {
EuiComboBox,
EuiFlexGroup,
EuiFlexItem,
EuiText,
EuiIcon,
EuiComboBoxOptionOption,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useState } from 'react';
import React, { useCallback, useState, useMemo } from 'react';
import { METRICS_EXPLORER_API_MAX_METRICS } from '@kbn/metrics-data-access-plugin/common';
import { useMetricsDataViewContext } from '../../../../containers/metrics_source';
import { colorTransformer, Color } from '../../../../../common/color_palette';
import { MetricsExplorerMetric } from '../../../../../common/http_api/metrics_explorer';
@ -24,11 +32,22 @@ interface SelectedOption {
label: string;
}
const placeholderText = i18n.translate('xpack.infra.metricsExplorer.metricComboBoxPlaceholder', {
defaultMessage: 'choose a metric to plot',
});
const comboValidationText = i18n.translate('xpack.infra.metricsExplorer.maxItemsSelected', {
defaultMessage: 'Maximum number of {maxMetrics} metrics reached.',
values: { maxMetrics: METRICS_EXPLORER_API_MAX_METRICS },
});
export const MetricsExplorerMetrics = ({ options, onChange, autoFocus = false }: Props) => {
const { metricsView } = useMetricsDataViewContext();
const colors = Object.keys(Color) as Array<keyof typeof Color>;
const [shouldFocus, setShouldFocus] = useState(autoFocus);
const maxMetricsReached = options.metrics.length >= METRICS_EXPLORER_API_MAX_METRICS;
// the EuiCombobox forwards the ref to an input element
const autoFocusInputElement = useCallback(
(inputElement: HTMLInputElement | null) => {
@ -53,10 +72,17 @@ export const MetricsExplorerMetrics = ({ options, onChange, autoFocus = false }:
[onChange, options.aggregation, colors]
);
const comboOptions = (metricsView?.fields ?? []).map((field) => ({
label: field.name,
value: field.name,
}));
const comboOptions = useMemo(
(): EuiComboBoxOptionOption[] =>
maxMetricsReached
? [{ label: comboValidationText, disabled: true }]
: (metricsView?.fields ?? []).map((field) => ({
label: field.name,
value: field.name,
})),
[maxMetricsReached, metricsView?.fields]
);
const selectedOptions = options.metrics
.filter((m) => m.aggregation !== 'count')
.map((metric) => ({
@ -65,9 +91,37 @@ export const MetricsExplorerMetrics = ({ options, onChange, autoFocus = false }:
color: colorTransformer(metric.color || Color.color0),
}));
const placeholderText = i18n.translate('xpack.infra.metricsExplorer.metricComboBoxPlaceholder', {
defaultMessage: 'choose a metric to plot',
});
const handleOnKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
if (maxMetricsReached) {
ev.preventDefault();
}
return ev;
};
const renderFields = useCallback((option: EuiComboBoxOptionOption) => {
const { label, disabled } = option;
if (disabled) {
return (
<EuiFlexGroup
direction="column"
justifyContent="center"
alignItems="center"
data-test-subj="infraMetricsExplorerMaxMetricsReached"
>
<EuiFlexItem>
<EuiFlexGroup gutterSize="xs" justifyContent="center" alignItems="center">
<EuiIcon type="iInCircle" size="s" />
<EuiText size="xs">{label}</EuiText>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return label;
}, []);
return (
<EuiComboBox
@ -79,8 +133,10 @@ export const MetricsExplorerMetrics = ({ options, onChange, autoFocus = false }:
options={comboOptions}
selectedOptions={selectedOptions}
onChange={handleChange}
isClearable={true}
onKeyDown={handleOnKeyDown}
isClearable
inputRef={autoFocusInputElement}
renderOption={renderFields}
/>
);
};

View file

@ -8,6 +8,7 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { decodeOrThrow } from '@kbn/io-ts-utils';
import { InfraHttpError } from '../../../../types';
import { useMetricsDataViewContext } from '../../../../containers/metrics_source';
import {
MetricsExplorerResponse,
@ -30,7 +31,7 @@ export function useMetricsExplorerData({
const { isLoading, data, error, refetch, fetchNextPage } = useInfiniteQuery<
MetricsExplorerResponse,
Error
InfraHttpError
>({
queryKey: ['metricExplorer', options, fromTimestamp, toTimestamp],
queryFn: async ({ signal, pageParam = { afterKey: null } }) => {
@ -77,11 +78,12 @@ export function useMetricsExplorerData({
getNextPageParam: (lastPage) => lastPage.pageInfo,
enabled: enabled && !!fromTimestamp && !!toTimestamp && !!http && !!metricsView,
refetchOnWindowFocus: false,
retry: false,
});
return {
data,
error,
error: error?.body || error,
fetchNextPage,
isLoading,
refetch,

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { TIMESTAMP_FIELD, CMDLINE_FIELD } from '../../../common/constants';
import { TIMESTAMP_FIELD, SYSTEM_PROCESS_CMDLINE_FIELD } from '../../../common/constants';
import { ProcessListAPIRequest, ProcessListAPIQueryAggregation } from '../../../common/http_api';
import { ESSearchClient } from '../metrics/types';
import type { InfraSourceConfiguration } from '../sources';
@ -69,7 +69,7 @@ export const getProcessList = async (
aggs: {
filteredProcs: {
terms: {
field: CMDLINE_FIELD,
field: SYSTEM_PROCESS_CMDLINE_FIELD,
size: TOP_N,
order: {
[sortBy.name]: sortBy.isAscending ? 'asc' : 'desc',

View file

@ -6,7 +6,7 @@
*/
import { first } from 'lodash';
import { TIMESTAMP_FIELD, CMDLINE_FIELD } from '../../../common/constants';
import { TIMESTAMP_FIELD, SYSTEM_PROCESS_CMDLINE_FIELD } from '../../../common/constants';
import {
ProcessListAPIChartRequest,
ProcessListAPIChartQueryAggregation,
@ -48,7 +48,7 @@ export const getProcessListChart = async (
must: [
{
match: {
[CMDLINE_FIELD]: command,
[SYSTEM_PROCESS_CMDLINE_FIELD]: command,
},
},
],
@ -57,7 +57,7 @@ export const getProcessListChart = async (
aggs: {
filteredProc: {
terms: {
field: CMDLINE_FIELD,
field: SYSTEM_PROCESS_CMDLINE_FIELD,
size: 1,
},
aggs: {

View file

@ -1,85 +0,0 @@
/*
* 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 Boom from '@hapi/boom';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { schema } from '@kbn/config-schema';
import { throwErrors } from '@kbn/io-ts-utils';
import { InfraBackendLibs } from '../../lib/infra_types';
import {
metricsExplorerRequestBodyRT,
metricsExplorerResponseRT,
MetricsExplorerPageInfo,
} from '../../../common/http_api';
import { convertRequestToMetricsAPIOptions } from './lib/convert_request_to_metrics_api_options';
import { createSearchClient } from '../../lib/create_search_client';
import { findIntervalForMetrics } from './lib/find_interval_for_metrics';
import { query } from '../../lib/metrics';
import { queryTotalGroupings } from './lib/query_total_groupings';
import { transformSeries } from './lib/transform_series';
const escapeHatch = schema.object({}, { unknowns: 'allow' });
export const initMetricExplorerRoute = (libs: InfraBackendLibs) => {
const { framework } = libs;
framework.registerRoute(
{
method: 'post',
path: '/api/infra/metrics_explorer',
validate: {
body: escapeHatch,
},
},
async (requestContext, request, response) => {
const options = pipe(
metricsExplorerRequestBodyRT.decode(request.body),
fold(throwErrors(Boom.badRequest), identity)
);
const client = createSearchClient(requestContext, framework);
const interval = await findIntervalForMetrics(client, options);
const optionsWithInterval = options.forceInterval
? options
: {
...options,
timerange: {
...options.timerange,
interval: interval ? `>=${interval}s` : options.timerange.interval,
},
};
const metricsApiOptions = convertRequestToMetricsAPIOptions(optionsWithInterval);
const metricsApiResponse = await query(client, metricsApiOptions);
const totalGroupings = await queryTotalGroupings(client, metricsApiOptions);
const hasGroupBy =
Array.isArray(metricsApiOptions.groupBy) && metricsApiOptions.groupBy.length > 0;
const pageInfo: MetricsExplorerPageInfo = {
total: totalGroupings,
afterKey: null,
};
if (metricsApiResponse.info.afterKey) {
pageInfo.afterKey = metricsApiResponse.info.afterKey;
}
// If we have a groupBy but there are ZERO groupings returned then we need to
// return an empty array. Otherwise we transform the series to match the current schema.
const series =
hasGroupBy && totalGroupings === 0
? []
: metricsApiResponse.series.map(transformSeries(hasGroupBy));
return response.ok({
body: metricsExplorerResponseRT.encode({ series, pageInfo }),
});
}
);
};

View file

@ -1,89 +0,0 @@
/*
* 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 { convertMetricToMetricsAPIMetric } from './convert_metric_to_metrics_api_metric';
import {
MetricsExplorerMetric,
MetricsAPIMetric,
MetricsExplorerAggregation,
} from '../../../../common/http_api';
describe('convertMetricToMetricsAPIMetric(metric, index)', () => {
const runTest = (metric: MetricsExplorerMetric, aggregation: MetricsAPIMetric) =>
it(`should convert ${metric.aggregation}`, () => {
expect(convertMetricToMetricsAPIMetric(metric, 1)).toEqual(aggregation);
});
const runTestForBasic = (aggregation: MetricsExplorerAggregation) =>
runTest(
{ aggregation, field: 'system.cpu.user.pct' },
{
id: 'metric_1',
aggregations: { metric_1: { [aggregation]: { field: 'system.cpu.user.pct' } } },
}
);
runTestForBasic('avg');
runTestForBasic('sum');
runTestForBasic('max');
runTestForBasic('min');
runTestForBasic('cardinality');
runTest(
{ aggregation: 'rate', field: 'test.field.that.is.a.counter' },
{
id: 'metric_1',
aggregations: {
metric_1_max: {
max: {
field: 'test.field.that.is.a.counter',
},
},
metric_1_deriv: {
derivative: {
buckets_path: 'metric_1_max',
gap_policy: 'skip',
unit: '1s',
},
},
metric_1: {
bucket_script: {
buckets_path: {
value: 'metric_1_deriv[normalized_value]',
},
gap_policy: 'skip',
script: {
lang: 'painless',
source: 'params.value > 0.0 ? params.value : 0.0',
},
},
},
},
}
);
runTest(
{ aggregation: 'count' },
{
id: 'metric_1',
aggregations: {
metric_1: {
bucket_script: {
buckets_path: {
count: '_count',
},
gap_policy: 'skip',
script: {
lang: 'expression',
source: 'count * 1',
},
},
},
},
}
);
});

View file

@ -1,82 +0,0 @@
/*
* 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 { isEmpty } from 'lodash';
import { networkTraffic } from '@kbn/metrics-data-access-plugin/common';
import { MetricsAPIMetric, MetricsExplorerMetric } from '../../../../common/http_api';
import { createCustomMetricsAggregations } from '../../../lib/create_custom_metrics_aggregations';
export const convertMetricToMetricsAPIMetric = (
metric: MetricsExplorerMetric,
index: number
): MetricsAPIMetric | undefined => {
const id = `metric_${index}`;
if (metric.aggregation === 'rate' && metric.field) {
return {
id,
aggregations: networkTraffic(id, metric.field),
};
}
if (['p95', 'p99'].includes(metric.aggregation) && metric.field) {
const percent = metric.aggregation === 'p95' ? 95 : 99;
return {
id,
aggregations: {
[id]: {
percentiles: {
field: metric.field,
percents: [percent],
},
},
},
};
}
if (['max', 'min', 'avg', 'cardinality', 'sum'].includes(metric.aggregation) && metric.field) {
return {
id,
aggregations: {
[id]: {
[metric.aggregation]: { field: metric.field },
},
},
};
}
if (metric.aggregation === 'count') {
return {
id,
aggregations: {
[id]: {
bucket_script: {
buckets_path: { count: '_count' },
script: {
source: 'count * 1',
lang: 'expression',
},
gap_policy: 'skip',
},
},
},
};
}
if (metric.aggregation === 'custom' && metric.custom_metrics) {
const customMetricAggregations = createCustomMetricsAggregations(
id,
metric.custom_metrics,
metric.equation
);
if (!isEmpty(customMetricAggregations)) {
return {
id,
aggregations: customMetricAggregations,
};
}
}
};

View file

@ -1,135 +0,0 @@
/*
* 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 { MetricsExplorerRequestBody, MetricsAPIRequest } from '../../../../common/http_api';
import { convertRequestToMetricsAPIOptions } from './convert_request_to_metrics_api_options';
const BASE_REQUEST: MetricsExplorerRequestBody = {
timerange: {
from: new Date('2020-01-01T00:00:00Z').getTime(),
to: new Date('2020-01-01T01:00:00Z').getTime(),
interval: '1m',
},
limit: 9,
indexPattern: 'metrics-*',
metrics: [{ aggregation: 'avg', field: 'system.cpu.user.pct' }],
};
const BASE_METRICS_UI_OPTIONS: MetricsAPIRequest = {
timerange: {
from: new Date('2020-01-01T00:00:00Z').getTime(),
to: new Date('2020-01-01T01:00:00Z').getTime(),
interval: '1m',
},
limit: 9,
dropPartialBuckets: true,
indexPattern: 'metrics-*',
metrics: [
{ id: 'metric_0', aggregations: { metric_0: { avg: { field: 'system.cpu.user.pct' } } } },
],
includeTimeseries: true,
};
describe('convertRequestToMetricsAPIOptions', () => {
it('should just work', () => {
expect(convertRequestToMetricsAPIOptions(BASE_REQUEST)).toEqual(BASE_METRICS_UI_OPTIONS);
});
it('should work with string afterKeys', () => {
expect(convertRequestToMetricsAPIOptions({ ...BASE_REQUEST, afterKey: 'host.name' })).toEqual({
...BASE_METRICS_UI_OPTIONS,
afterKey: { groupBy0: 'host.name' },
});
});
it('should work with afterKey objects', () => {
const afterKey = { groupBy0: 'host.name', groupBy1: 'cloud.availability_zone' };
expect(
convertRequestToMetricsAPIOptions({
...BASE_REQUEST,
afterKey,
})
).toEqual({
...BASE_METRICS_UI_OPTIONS,
afterKey,
});
});
it('should work with string group bys', () => {
expect(
convertRequestToMetricsAPIOptions({
...BASE_REQUEST,
groupBy: 'host.name',
})
).toEqual({
...BASE_METRICS_UI_OPTIONS,
groupBy: ['host.name'],
});
});
it('should work with group by arrays', () => {
expect(
convertRequestToMetricsAPIOptions({
...BASE_REQUEST,
groupBy: ['host.name', 'cloud.availability_zone'],
})
).toEqual({
...BASE_METRICS_UI_OPTIONS,
groupBy: ['host.name', 'cloud.availability_zone'],
});
});
it('should work with filterQuery json string', () => {
const filter = { bool: { filter: [{ match: { 'host.name': 'example-01' } }] } };
expect(
convertRequestToMetricsAPIOptions({
...BASE_REQUEST,
filterQuery: JSON.stringify(filter),
})
).toEqual({
...BASE_METRICS_UI_OPTIONS,
filters: [filter],
});
});
it('should work with filterQuery as Lucene expressions', () => {
const filter = `host.name: 'example-01'`;
expect(
convertRequestToMetricsAPIOptions({
...BASE_REQUEST,
filterQuery: filter,
})
).toEqual({
...BASE_METRICS_UI_OPTIONS,
filters: [{ query_string: { query: filter, analyze_wildcard: true } }],
});
});
it('should work with empty metrics', () => {
expect(
convertRequestToMetricsAPIOptions({
...BASE_REQUEST,
metrics: [],
})
).toEqual({
...BASE_METRICS_UI_OPTIONS,
metrics: [],
});
});
it('should work with empty field', () => {
expect(
convertRequestToMetricsAPIOptions({
...BASE_REQUEST,
metrics: [{ aggregation: 'avg' }],
})
).toEqual({
...BASE_METRICS_UI_OPTIONS,
metrics: [],
});
});
});

View file

@ -1,62 +0,0 @@
/*
* 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 { isObject, isArray } from 'lodash';
import {
MetricsAPIRequest,
MetricsExplorerRequestBody,
afterKeyObjectRT,
} from '../../../../common/http_api';
import { convertMetricToMetricsAPIMetric } from './convert_metric_to_metrics_api_metric';
export const convertRequestToMetricsAPIOptions = (
options: MetricsExplorerRequestBody
): MetricsAPIRequest => {
const metrics = options.metrics
.map(convertMetricToMetricsAPIMetric)
.filter(<M>(m: M): m is NonNullable<M> => !!m);
const { limit, timerange, indexPattern } = options;
const metricsApiOptions: MetricsAPIRequest = {
timerange,
indexPattern,
limit,
metrics,
dropPartialBuckets: true,
includeTimeseries: true,
};
if (options.afterKey) {
metricsApiOptions.afterKey = afterKeyObjectRT.is(options.afterKey)
? options.afterKey
: { groupBy0: options.afterKey };
}
if (options.groupBy) {
metricsApiOptions.groupBy = isArray(options.groupBy) ? options.groupBy : [options.groupBy];
}
if (options.filterQuery) {
try {
const filterObject = JSON.parse(options.filterQuery);
if (isObject(filterObject)) {
metricsApiOptions.filters = [filterObject as any];
}
} catch (err) {
metricsApiOptions.filters = [
{
query_string: {
query: options.filterQuery,
analyze_wildcard: true,
},
},
];
}
}
return metricsApiOptions;
};

View file

@ -1,53 +0,0 @@
/*
* 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 { uniq } from 'lodash';
import LRU from 'lru-cache';
import { MetricsExplorerRequestBody } from '../../../../common/http_api';
import { getDatasetForField } from './get_dataset_for_field';
import { calculateMetricInterval } from '../../../utils/calculate_metric_interval';
import { ESSearchClient } from '../../../lib/metrics/types';
const cache = new LRU({
max: 100,
maxAge: 15 * 60 * 1000,
});
export const findIntervalForMetrics = async (
client: ESSearchClient,
options: MetricsExplorerRequestBody
) => {
const fields = uniq(
options.metrics.map((metric) => (metric.field ? metric.field : null)).filter((f) => f)
) as string[];
const cacheKey = fields.sort().join(':');
if (cache.has(cacheKey)) return cache.get(cacheKey);
if (fields.length === 0) {
return 60;
}
const modules = await Promise.all(
fields.map(
async (field) =>
await getDatasetForField(client, field as string, options.indexPattern, options.timerange)
)
);
const interval = calculateMetricInterval(
client,
{
indexPattern: options.indexPattern,
timerange: options.timerange,
},
modules.filter(Boolean) as string[]
);
cache.set(cacheKey, interval);
return interval;
};

View file

@ -1,128 +0,0 @@
/*
* 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 { MetricsAPIRequest } from '../../../../common/http_api';
import { queryTotalGroupings } from './query_total_groupings';
describe('queryTotalGroupings', () => {
const ESSearchClientMock = jest.fn().mockReturnValue({});
const defaultOptions: MetricsAPIRequest = {
timerange: {
from: 1615972672011,
interval: '>=10s',
to: 1615976272012,
},
indexPattern: 'testIndexPattern',
metrics: [],
dropPartialBuckets: true,
groupBy: ['testField'],
includeTimeseries: true,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should return 0 when there is no groupBy', async () => {
const { groupBy, ...options } = defaultOptions;
const response = await queryTotalGroupings(ESSearchClientMock, options);
expect(response).toBe(0);
});
it('should return 0 when there is groupBy is empty', async () => {
const options = {
...defaultOptions,
groupBy: [],
};
const response = await queryTotalGroupings(ESSearchClientMock, options);
expect(response).toBe(0);
});
it('should query ES with a timerange', async () => {
await queryTotalGroupings(ESSearchClientMock, defaultOptions);
expect(ESSearchClientMock.mock.calls[0][0].body.query.bool.filter).toContainEqual({
range: {
'@timestamp': {
gte: 1615972672011,
lte: 1615976272012,
format: 'epoch_millis',
},
},
});
});
it('should query ES with a exist fields', async () => {
const options = {
...defaultOptions,
groupBy: ['testField1', 'testField2'],
};
await queryTotalGroupings(ESSearchClientMock, options);
expect(ESSearchClientMock.mock.calls[0][0].body.query.bool.filter).toContainEqual({
exists: { field: 'testField1' },
});
expect(ESSearchClientMock.mock.calls[0][0].body.query.bool.filter).toContainEqual({
exists: { field: 'testField2' },
});
});
it('should query ES with a query filter', async () => {
const options = {
...defaultOptions,
filters: [
{
bool: {
should: [{ match_phrase: { field1: 'value1' } }],
minimum_should_match: 1,
},
},
],
};
await queryTotalGroupings(ESSearchClientMock, options);
expect(ESSearchClientMock.mock.calls[0][0].body.query.bool.filter).toContainEqual({
bool: {
should: [
{
match_phrase: {
field1: 'value1',
},
},
],
minimum_should_match: 1,
},
});
});
it('should return 0 when there are no aggregations in the response', async () => {
const clientMock = jest.fn().mockReturnValue({});
const response = await queryTotalGroupings(clientMock, defaultOptions);
expect(response).toBe(0);
});
it('should return the value of the aggregation in the response', async () => {
const clientMock = jest.fn().mockReturnValue({
aggregations: {
count: {
value: 10,
},
},
});
const response = await queryTotalGroupings(clientMock, defaultOptions);
expect(response).toBe(10);
});
});

View file

@ -1,67 +0,0 @@
/*
* 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 { isArray } from 'lodash';
import { TIMESTAMP_FIELD } from '../../../../common/constants';
import { MetricsAPIRequest } from '../../../../common/http_api';
import { ESSearchClient } from '../../../lib/metrics/types';
interface GroupingResponse {
count: {
value: number;
};
}
export const queryTotalGroupings = async (
client: ESSearchClient,
options: MetricsAPIRequest
): Promise<number> => {
if (!options.groupBy || (isArray(options.groupBy) && options.groupBy.length === 0)) {
return Promise.resolve(0);
}
let filters: Array<Record<string, any>> = [
{
range: {
[TIMESTAMP_FIELD]: {
gte: options.timerange.from,
lte: options.timerange.to,
format: 'epoch_millis',
},
},
},
...options.groupBy.map((field) => ({ exists: { field } })),
];
if (options.filters) {
filters = [...filters, ...options.filters];
}
const params = {
allow_no_indices: true,
ignore_unavailable: true,
index: options.indexPattern,
body: {
size: 0,
query: {
bool: {
filter: filters,
},
},
aggs: {
count: {
cardinality: {
script: options.groupBy.map((field) => `doc['${field}'].value`).join('+'),
},
},
},
},
};
const response = await client<{}, GroupingResponse>(params);
return response.aggregations?.count.value ?? 0;
};

View file

@ -1,27 +0,0 @@
/*
* 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 { MetricsAPISeries, MetricsExplorerSeries } from '../../../../common/http_api';
export const transformSeries =
(hasGroupBy: boolean) =>
(series: MetricsAPISeries): MetricsExplorerSeries => {
const id = series.keys?.join(' / ') ?? series.id;
return {
...series,
id,
rows: series.rows.map((row) => {
if (hasGroupBy) {
return { ...row, groupBy: id };
}
return row;
}),
columns: hasGroupBy
? [...series.columns, { name: 'groupBy', type: 'string' }]
: series.columns,
};
};

View file

@ -11,7 +11,7 @@ import { MetricsAPITimerange } from '../../../../common/http_api';
import { ESSearchClient } from '../../../lib/metrics/types';
import { calculateMetricInterval } from '../../../utils/calculate_metric_interval';
import { getMetricsAggregations, InfraSnapshotRequestOptions } from './get_metrics_aggregations';
import { getDatasetForField } from '../../metrics_explorer/lib/get_dataset_for_field';
import { getDatasetForField } from './get_dataset_for_field';
const DEFAULT_LOOKBACK_SIZE = 5;
const createInterval = async (client: ESSearchClient, options: InfraSnapshotRequestOptions) => {

View file

@ -5,31 +5,9 @@
* 2.0.
*/
export const METRICS_INDEX_PATTERN = 'metrics-*,metricbeat-*';
export const LOGS_INDEX_PATTERN = 'logs-*,filebeat-*,kibana_sample_data_logs*';
export const METRICS_APP = 'metrics';
export const LOGS_APP = 'logs';
export const METRICS_FEATURE_ID = 'infrastructure';
export const LOGS_FEATURE_ID = 'logs';
export type InfraFeatureId = typeof METRICS_FEATURE_ID | typeof LOGS_FEATURE_ID;
export const TIMESTAMP_FIELD = '@timestamp';
export const MESSAGE_FIELD = 'message';
export const TIEBREAKER_FIELD = '_doc';
export const HOST_FIELD = 'host.name';
export const CONTAINER_FIELD = 'container.id';
export const POD_FIELD = 'kubernetes.pod.uid';
export const DISCOVER_APP_TARGET = 'discover';
export const LOGS_APP_TARGET = 'logs-ui';
export const O11Y_AAD_FIELDS = [
'cloud.*',
'host.*',
'orchestrator.*',
'container.*',
'labels.*',
'tags',
];
export const METRICS_EXPLORER_API_MAX_METRICS = 20;

View file

@ -47,3 +47,4 @@ export type {
} from './inventory_models/types';
export { networkTraffic } from './inventory_models/shared/metrics/snapshot/network_traffic';
export { METRICS_EXPLORER_API_MAX_METRICS } from './constants';

View file

@ -5,12 +5,9 @@
* 2.0.
*/
import { createRouteValidationFunction } from '@kbn/io-ts-utils';
import Boom from '@hapi/boom';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { schema } from '@kbn/config-schema';
import { throwErrors } from '@kbn/io-ts-utils';
import { METRICS_EXPLORER_API_MAX_METRICS } from '../../../common/constants';
import {
metricsExplorerRequestBodyRT,
metricsExplorerResponseRT,
@ -24,61 +21,79 @@ import { queryTotalGroupings } from './lib/query_total_groupings';
import { transformSeries } from './lib/transform_series';
import { KibanaFramework } from '../../lib/adapters/framework/kibana_framework_adapter';
const escapeHatch = schema.object({}, { unknowns: 'allow' });
export const initMetricExplorerRoute = (framework: KibanaFramework) => {
const validateBody = createRouteValidationFunction(metricsExplorerRequestBodyRT);
framework.registerRoute(
{
method: 'post',
path: '/api/infra/metrics_explorer',
validate: {
body: escapeHatch,
body: validateBody,
},
},
async (requestContext, request, response) => {
const options = pipe(
metricsExplorerRequestBodyRT.decode(request.body),
fold(throwErrors(Boom.badRequest), identity)
);
const options = request.body;
const client = createSearchClient(requestContext, framework);
const interval = await findIntervalForMetrics(client, options);
try {
if (options.metrics.length > METRICS_EXPLORER_API_MAX_METRICS) {
throw Boom.badRequest(
`'metrics' size is greater than maximum of ${METRICS_EXPLORER_API_MAX_METRICS} allowed.`
);
}
const optionsWithInterval = options.forceInterval
? options
: {
...options,
timerange: {
...options.timerange,
interval: interval ? `>=${interval}s` : options.timerange.interval,
},
};
const client = createSearchClient(requestContext, framework);
const interval = await findIntervalForMetrics(client, options);
const metricsApiOptions = convertRequestToMetricsAPIOptions(optionsWithInterval);
const metricsApiResponse = await query(client, metricsApiOptions);
const totalGroupings = await queryTotalGroupings(client, metricsApiOptions);
const hasGroupBy =
Array.isArray(metricsApiOptions.groupBy) && metricsApiOptions.groupBy.length > 0;
const optionsWithInterval = options.forceInterval
? options
: {
...options,
timerange: {
...options.timerange,
interval: interval ? `>=${interval}s` : options.timerange.interval,
},
};
const pageInfo: MetricsExplorerPageInfo = {
total: totalGroupings,
afterKey: null,
};
const metricsApiOptions = convertRequestToMetricsAPIOptions(optionsWithInterval);
const metricsApiResponse = await query(client, metricsApiOptions);
const totalGroupings = await queryTotalGroupings(client, metricsApiOptions);
const hasGroupBy =
Array.isArray(metricsApiOptions.groupBy) && metricsApiOptions.groupBy.length > 0;
if (metricsApiResponse.info.afterKey) {
pageInfo.afterKey = metricsApiResponse.info.afterKey;
const pageInfo: MetricsExplorerPageInfo = {
total: totalGroupings,
afterKey: null,
};
if (metricsApiResponse.info.afterKey) {
pageInfo.afterKey = metricsApiResponse.info.afterKey;
}
// If we have a groupBy but there are ZERO groupings returned then we need to
// return an empty array. Otherwise we transform the series to match the current schema.
const series =
hasGroupBy && totalGroupings === 0
? []
: metricsApiResponse.series.map(transformSeries(hasGroupBy));
return response.ok({
body: metricsExplorerResponseRT.encode({ series, pageInfo }),
});
} catch (err) {
if (Boom.isBoom(err)) {
return response.customError({
statusCode: err.output.statusCode,
body: { message: err.output.payload.message },
});
}
return response.customError({
statusCode: err.statusCode ?? 500,
body: {
message: err.message ?? 'An unexpected error occurred',
},
});
}
// If we have a groupBy but there are ZERO groupings returned then we need to
// return an empty array. Otherwise we transform the series to match the current schema.
const series =
hasGroupBy && totalGroupings === 0
? []
: metricsApiResponse.series.map(transformSeries(hasGroupBy));
return response.ok({
body: metricsExplorerResponseRT.encode({ series, pageInfo }),
});
}
);
};

View file

@ -26,7 +26,6 @@
"@kbn/core-http-server",
"@kbn/datemath",
"@kbn/es-types",
"@kbn/config-schema",
"@kbn/es-query",
"@kbn/kibana-react-plugin",
"@kbn/shared-ux-page-kibana-template",

View file

@ -35,13 +35,14 @@ export default function ({ getService }: FtrProviderContext) {
after(() => esArchiver.unload('x-pack/test/functional/es_archives/infra/8.0.0/pods_only'));
const fetchNodeDetails = async (
body: NodeDetailsRequest
body: NodeDetailsRequest,
expectedStatusCode = 200
): Promise<NodeDetailsMetricDataResponse | undefined> => {
const response = await supertest
.post('/api/metrics/node_details')
.set('kbn-xsrf', 'xxx')
.send(body)
.expect(200);
.expect(expectedStatusCode);
return response.body;
};

View file

@ -242,6 +242,31 @@ export default function ({ getService }: FtrProviderContext) {
total: 4,
});
});
it('should return 400 when requesting more than 20 metrics', async () => {
const postBody = {
timerange: {
field: '@timestamp',
to: max,
from: min,
interval: '>=1m',
},
indexPattern: 'metricbeat-*',
groupBy: ['host.name', 'system.network.name'],
limit: 3,
afterKey: null,
metrics: Array(21).fill({
aggregation: 'rate',
field: 'system.network.out.bytes',
}),
};
await supertest
.post('/api/infra/metrics_explorer')
.set('kbn-xsrf', 'xxx')
.send(postBody)
.expect(400);
});
});
describe('without data', () => {

View file

@ -101,6 +101,39 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
it('renders the metrics explorer survey link', async () => {
await pageObjects.infraMetricsExplorer.ensureMetricsExplorerFeedbackLinkIsVisible();
});
it('should not allow adding more than 20 metrics', async () => {
await pageObjects.infraMetricsExplorer.clearMetrics();
const fields = [
'process.cpu.pct',
'process.memory.pct',
'system.core.total.pct',
'system.core.user.pct',
'system.core.nice.pct',
'system.core.idle.pct',
'system.core.iowait.pct',
'system.core.irq.pct',
'system.core.softirq.pct',
'system.core.steal.pct',
'system.cpu.nice.pct',
'system.cpu.idle.pct',
'system.cpu.iowait.pct',
'system.cpu.irq.pct',
'system.cpu.softirq.pct',
'system.cpu.steal.pct',
'system.cpu.user.norm.pct',
'system.memory.free',
'kubernetes.pod.cpu.usage.node.pct',
'docker.cpu.total.pct',
];
for (const field of fields) {
await pageObjects.infraMetricsExplorer.addMetric(field);
}
await pageObjects.infraMetricsExplorer.ensureMaxMetricsLimiteReachedIsVisible();
});
});
describe('Saved Views', function () {

View file

@ -13,6 +13,10 @@ export function InfraMetricsExplorerProvider({ getService }: FtrProviderContext)
const comboBox = getService('comboBox');
return {
async clearMetrics() {
await comboBox.clear('metricsExplorer-metrics');
},
async getMetrics() {
const subject = await testSubjects.find('metricsExplorer-metrics');
return await subject.findAllByCssSelector('span.euiBadge');
@ -61,5 +65,11 @@ export function InfraMetricsExplorerProvider({ getService }: FtrProviderContext)
await testSubjects.missingOrFail('loadingMessage', { timeout: 20000 });
await testSubjects.existOrFail('infraMetricsExplorerFeedbackLink');
},
async ensureMaxMetricsLimiteReachedIsVisible() {
const subject = await testSubjects.find('metricsExplorer-metrics');
await subject.click();
await testSubjects.existOrFail('infraMetricsExplorerMaxMetricsReached');
},
};
}