diff --git a/x-pack/plugins/observability_solution/infra/common/alerting/metrics/alert_link.ts b/x-pack/plugins/observability_solution/infra/common/alerting/metrics/alert_link.ts index a1f0da3531215..06d34a83f123a 100644 --- a/x-pack/plugins/observability_solution/infra/common/alerting/metrics/alert_link.ts +++ b/x-pack/plugins/observability_solution/infra/common/alerting/metrics/alert_link.ts @@ -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) => { - const hostName = fields[HOST_FIELD]; + const hostName = fields[HOST_NAME_FIELD]; const timestamp = fields[TIMESTAMP]; return hostName ? getLinkToHostDetails({ hostName, timestamp }) : METRICS_EXPLORER_URL; diff --git a/x-pack/plugins/observability_solution/infra/common/constants.ts b/x-pack/plugins/observability_solution/infra/common/constants.ts index 580dcd16dec09..de62eb835aa98 100644 --- a/x-pack/plugins/observability_solution/infra/common/constants.ts +++ b/x-pack/plugins/observability_solution/infra/common/constants.ts @@ -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.*', diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_profiling_kuery.ts b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_profiling_kuery.ts index 10fb9b90cb090..56be7aae0e15a 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_profiling_kuery.ts +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_profiling_kuery.ts @@ -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; diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/overview/services.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/overview/services.tsx index edd6bebb4e96d..7336d6bfe6782 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/overview/services.tsx +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/overview/services.tsx @@ -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, }), diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/profiling/profiling_links.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/profiling/profiling_links.tsx index 1509da65d24da..d9619a9928660 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/profiling/profiling_links.tsx +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/profiling/profiling_links.tsx @@ -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, }); diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx index 2821428d9fa4b..0639d026c2e15 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx @@ -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'; } }; diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/metrics_explorer/components/metrics.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/metrics_explorer/components/metrics.tsx index e6c8aa957c26c..4e2764d9222e0 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/metrics_explorer/components/metrics.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/metrics_explorer/components/metrics.tsx @@ -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; 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) => { + if (maxMetricsReached) { + ev.preventDefault(); + } + + return ev; + }; + + const renderFields = useCallback((option: EuiComboBoxOptionOption) => { + const { label, disabled } = option; + + if (disabled) { + return ( + + + + + {label} + + + + ); + } + + return label; + }, []); return ( ); }; diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts b/x-pack/plugins/observability_solution/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts index 03c75055e46d8..b3ec0dd1f0b1d 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts @@ -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, diff --git a/x-pack/plugins/observability_solution/infra/server/lib/host_details/process_list.ts b/x-pack/plugins/observability_solution/infra/server/lib/host_details/process_list.ts index fa9cb52ee13df..1ffe0e15f0677 100644 --- a/x-pack/plugins/observability_solution/infra/server/lib/host_details/process_list.ts +++ b/x-pack/plugins/observability_solution/infra/server/lib/host_details/process_list.ts @@ -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', diff --git a/x-pack/plugins/observability_solution/infra/server/lib/host_details/process_list_chart.ts b/x-pack/plugins/observability_solution/infra/server/lib/host_details/process_list_chart.ts index 95b51d07072c3..45e43d3f9f2a0 100644 --- a/x-pack/plugins/observability_solution/infra/server/lib/host_details/process_list_chart.ts +++ b/x-pack/plugins/observability_solution/infra/server/lib/host_details/process_list_chart.ts @@ -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: { diff --git a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/index.ts b/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/index.ts deleted file mode 100644 index a6db46e73dfeb..0000000000000 --- a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/index.ts +++ /dev/null @@ -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 }), - }); - } - ); -}; diff --git a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/convert_metric_to_metrics_api_metric.test.ts b/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/convert_metric_to_metrics_api_metric.test.ts deleted file mode 100644 index 6e391aeb45246..0000000000000 --- a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/convert_metric_to_metrics_api_metric.test.ts +++ /dev/null @@ -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', - }, - }, - }, - }, - } - ); -}); diff --git a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/convert_metric_to_metrics_api_metric.ts b/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/convert_metric_to_metrics_api_metric.ts deleted file mode 100644 index 12ecca8d45ee8..0000000000000 --- a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/convert_metric_to_metrics_api_metric.ts +++ /dev/null @@ -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, - }; - } - } -}; diff --git a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/convert_request_to_metrics_api_options.test.ts b/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/convert_request_to_metrics_api_options.test.ts deleted file mode 100644 index fdf58fa848f8f..0000000000000 --- a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/convert_request_to_metrics_api_options.test.ts +++ /dev/null @@ -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: [], - }); - }); -}); diff --git a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/convert_request_to_metrics_api_options.ts b/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/convert_request_to_metrics_api_options.ts deleted file mode 100644 index 144be0565e298..0000000000000 --- a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/convert_request_to_metrics_api_options.ts +++ /dev/null @@ -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 is NonNullable => !!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; -}; diff --git a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/find_interval_for_metrics.ts b/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/find_interval_for_metrics.ts deleted file mode 100644 index 62e99cf8ffd32..0000000000000 --- a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/find_interval_for_metrics.ts +++ /dev/null @@ -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; -}; diff --git a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/query_total_grouping.test.ts b/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/query_total_grouping.test.ts deleted file mode 100644 index 1844a994a9375..0000000000000 --- a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/query_total_grouping.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/query_total_groupings.ts b/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/query_total_groupings.ts deleted file mode 100644 index b2e22752609c1..0000000000000 --- a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/query_total_groupings.ts +++ /dev/null @@ -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 => { - if (!options.groupBy || (isArray(options.groupBy) && options.groupBy.length === 0)) { - return Promise.resolve(0); - } - - let filters: Array> = [ - { - 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; -}; diff --git a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/transform_series.ts b/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/transform_series.ts deleted file mode 100644 index 6b876887bd568..0000000000000 --- a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/transform_series.ts +++ /dev/null @@ -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, - }; - }; diff --git a/x-pack/plugins/observability_solution/infra/server/routes/snapshot/lib/create_timerange_with_interval.ts b/x-pack/plugins/observability_solution/infra/server/routes/snapshot/lib/create_timerange_with_interval.ts index 4d3a45890bedf..14c418141f99b 100644 --- a/x-pack/plugins/observability_solution/infra/server/routes/snapshot/lib/create_timerange_with_interval.ts +++ b/x-pack/plugins/observability_solution/infra/server/routes/snapshot/lib/create_timerange_with_interval.ts @@ -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) => { diff --git a/x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/get_dataset_for_field.ts b/x-pack/plugins/observability_solution/infra/server/routes/snapshot/lib/get_dataset_for_field.ts similarity index 100% rename from x-pack/plugins/observability_solution/infra/server/routes/metrics_explorer/lib/get_dataset_for_field.ts rename to x-pack/plugins/observability_solution/infra/server/routes/snapshot/lib/get_dataset_for_field.ts diff --git a/x-pack/plugins/observability_solution/metrics_data_access/common/constants.ts b/x-pack/plugins/observability_solution/metrics_data_access/common/constants.ts index 9dd43c6e72c99..d801cc214ecaf 100644 --- a/x-pack/plugins/observability_solution/metrics_data_access/common/constants.ts +++ b/x-pack/plugins/observability_solution/metrics_data_access/common/constants.ts @@ -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; diff --git a/x-pack/plugins/observability_solution/metrics_data_access/common/index.ts b/x-pack/plugins/observability_solution/metrics_data_access/common/index.ts index a236ceeac16f0..12a4b6c4e13ce 100644 --- a/x-pack/plugins/observability_solution/metrics_data_access/common/index.ts +++ b/x-pack/plugins/observability_solution/metrics_data_access/common/index.ts @@ -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'; diff --git a/x-pack/plugins/observability_solution/metrics_data_access/server/routes/metrics_explorer/index.ts b/x-pack/plugins/observability_solution/metrics_data_access/server/routes/metrics_explorer/index.ts index 4d06b3263d52d..412b4089e773d 100644 --- a/x-pack/plugins/observability_solution/metrics_data_access/server/routes/metrics_explorer/index.ts +++ b/x-pack/plugins/observability_solution/metrics_data_access/server/routes/metrics_explorer/index.ts @@ -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)); + // 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 }), - }); + 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', + }, + }); + } } ); }; diff --git a/x-pack/plugins/observability_solution/metrics_data_access/tsconfig.json b/x-pack/plugins/observability_solution/metrics_data_access/tsconfig.json index b2ba77bff9f37..39ca16361f6cd 100644 --- a/x-pack/plugins/observability_solution/metrics_data_access/tsconfig.json +++ b/x-pack/plugins/observability_solution/metrics_data_access/tsconfig.json @@ -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", diff --git a/x-pack/test/api_integration/apis/metrics_ui/metrics.ts b/x-pack/test/api_integration/apis/metrics_ui/metrics.ts index 434238620f94c..9acdd82abc085 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/metrics.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/metrics.ts @@ -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 => { const response = await supertest .post('/api/metrics/node_details') .set('kbn-xsrf', 'xxx') .send(body) - .expect(200); + .expect(expectedStatusCode); return response.body; }; diff --git a/x-pack/test/api_integration/apis/metrics_ui/metrics_explorer.ts b/x-pack/test/api_integration/apis/metrics_ui/metrics_explorer.ts index 25734167320b6..7257e8583ab8a 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/metrics_explorer.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/metrics_explorer.ts @@ -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', () => { diff --git a/x-pack/test/functional/apps/infra/metrics_explorer.ts b/x-pack/test/functional/apps/infra/metrics_explorer.ts index 1f0490bf7f86b..9edde4b8979e6 100644 --- a/x-pack/test/functional/apps/infra/metrics_explorer.ts +++ b/x-pack/test/functional/apps/infra/metrics_explorer.ts @@ -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 () { diff --git a/x-pack/test/functional/page_objects/infra_metrics_explorer.ts b/x-pack/test/functional/page_objects/infra_metrics_explorer.ts index 4e691a164cdb7..334ecf68c5f65 100644 --- a/x-pack/test/functional/page_objects/infra_metrics_explorer.ts +++ b/x-pack/test/functional/page_objects/infra_metrics_explorer.ts @@ -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'); + }, }; }