mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
d71d48797d
commit
5ec5b994dc
29 changed files with 234 additions and 835 deletions
|
@ -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;
|
||||
|
|
|
@ -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.*',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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 }),
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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) => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 }),
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue