diff --git a/x-pack/legacy/plugins/apm/server/routes/services.ts b/x-pack/legacy/plugins/apm/server/routes/services.ts index 0c1f54c661f32..b466e601497dd 100644 --- a/x-pack/legacy/plugins/apm/server/routes/services.ts +++ b/x-pack/legacy/plugins/apm/server/routes/services.ts @@ -5,6 +5,7 @@ */ import * as t from 'io-ts'; +import { Legacy } from 'kibana'; import { AgentName } from '../../typings/es_schemas/ui/fields/Agent'; import { createApmTelementry, storeApmTelemetry } from '../lib/apm_telemetry'; import { setupRequest } from '../lib/helpers/setup_request'; @@ -14,12 +15,8 @@ import { getServiceTransactionTypes } from '../lib/services/get_service_transact import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; -export const servicesRoute = createRoute(core => ({ - path: '/api/apm/services', - params: { - query: t.intersection([uiFiltersRt, rangeRt]) - }, - handler: async req => { +export const servicesRoute = createRoute(core => { + const handler = async (req: Legacy.Request) => { const setup = await setupRequest(req); const services = await getServices(setup); const { server } = core.http; @@ -32,8 +29,18 @@ export const servicesRoute = createRoute(core => ({ storeApmTelemetry(server, apmTelemetry); return services; - } -})); + }; + // The Infra UI needs to be able to make requests to this endpoint without + // going through the server's router (via server.inject). + core.http.server.expose('getServices', handler); + return { + path: '/api/apm/services', + params: { + query: t.intersection([uiFiltersRt, rangeRt]) + }, + handler + }; +}); export const serviceAgentNameRoute = createRoute(() => ({ path: '/api/apm/services/{serviceName}/agent_name', diff --git a/x-pack/legacy/plugins/apm/server/routes/transaction_groups.ts b/x-pack/legacy/plugins/apm/server/routes/transaction_groups.ts index cde9fd1dd4ca9..b98b349822752 100644 --- a/x-pack/legacy/plugins/apm/server/routes/transaction_groups.ts +++ b/x-pack/legacy/plugins/apm/server/routes/transaction_groups.ts @@ -5,6 +5,7 @@ */ import * as t from 'io-ts'; +import { Legacy } from 'kibana'; import { setupRequest } from '../lib/helpers/setup_request'; import { getTransactionCharts } from '../lib/transactions/charts'; import { getTransactionDistribution } from '../lib/transactions/distribution'; @@ -44,22 +45,23 @@ export const transactionGroupsRoute = createRoute(() => ({ } })); -export const transactionGroupsChartsRoute = createRoute(() => ({ - path: '/api/apm/services/{serviceName}/transaction_groups/charts', - params: { - path: t.type({ - serviceName: t.string +export const transactionGroupsChartsRoute = createRoute(core => { + const PathRT = t.type({ + serviceName: t.string + }); + const QueryRT = t.intersection([ + t.partial({ + transactionType: t.string, + transactionName: t.string }), - query: t.intersection([ - t.partial({ - transactionType: t.string, - transactionName: t.string - }), - uiFiltersRt, - rangeRt - ]) - }, - handler: async (req, { path, query }) => { + uiFiltersRt, + rangeRt + ]); + interface Params { + path: t.TypeOf; + query: t.TypeOf; + } + const handler = async (req: Legacy.Request, { path, query }: Params) => { const setup = await setupRequest(req); const { serviceName } = path; const { transactionType, transactionName } = query; @@ -70,8 +72,19 @@ export const transactionGroupsChartsRoute = createRoute(() => ({ transactionName, setup }); - } -})); + }; + // The Infra UI needs to be able to make requests to this endpoint without + // going through the server's router (via server.inject). + core.http.server.expose('getTransactionGroupsCharts', handler); + return { + path: '/api/apm/services/{serviceName}/transaction_groups/charts', + params: { + path: PathRT, + query: QueryRT + }, + handler + }; +}); export const transactionGroupsDistributionRoute = createRoute(() => ({ path: '/api/apm/services/{serviceName}/transaction_groups/distribution', diff --git a/x-pack/legacy/plugins/infra/common/graphql/types.ts b/x-pack/legacy/plugins/infra/common/graphql/types.ts index 89d5608ad7c29..ab6803cefea98 100644 --- a/x-pack/legacy/plugins/infra/common/graphql/types.ts +++ b/x-pack/legacy/plugins/infra/common/graphql/types.ts @@ -590,6 +590,7 @@ export enum InfraMetric { nginxRequestRate = 'nginxRequestRate', nginxActiveConnections = 'nginxActiveConnections', nginxRequestsPerConnection = 'nginxRequestsPerConnection', + apmMetrics = 'apmMetrics', awsOverview = 'awsOverview', awsCpuUtilization = 'awsCpuUtilization', awsNetworkBytes = 'awsNetworkBytes', diff --git a/x-pack/legacy/plugins/infra/common/http_api/apm_metrics_api.ts b/x-pack/legacy/plugins/infra/common/http_api/apm_metrics_api.ts new file mode 100644 index 0000000000000..bc375e436f0c4 --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/http_api/apm_metrics_api.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as rt from 'io-ts'; +import { InfraWrappableRequest } from '../../server/lib/adapters/framework'; +import { InfraNodeTypeRT } from './common'; + +export const InfraApmMetricsRequestRT = rt.type({ + timeRange: rt.type({ + min: rt.number, + max: rt.number, + }), + nodeId: rt.string, + nodeType: InfraNodeTypeRT, + sourceId: rt.string, +}); + +export const InfraApmMetricsTransactionTypeRT = rt.keyof({ + request: null, + job: null, +}); + +export const InfraApmMetricsDataPointRT = rt.type({ + timestamp: rt.number, + value: rt.union([rt.number, rt.null]), +}); + +export const InfraApmMetricsSeriesRT = rt.type({ + id: rt.string, + data: rt.array(InfraApmMetricsDataPointRT), +}); + +export const InfraApmMetricsDataSetRT = rt.type({ + id: rt.string, + type: InfraApmMetricsTransactionTypeRT, + series: rt.array(InfraApmMetricsSeriesRT), +}); + +export const InfraApmMetricsServiceRT = rt.type({ + id: rt.string, + dataSets: rt.array(InfraApmMetricsDataSetRT), + agentName: rt.string, + avgResponseTime: rt.number, + errorsPerMinute: rt.number, + transactionsPerMinute: rt.number, +}); + +export const InfraApmMetricsRT = rt.type({ + id: rt.literal('apmMetrics'), + services: rt.array(InfraApmMetricsServiceRT), +}); + +export const APMDataPointRT = rt.type({ + x: rt.number, + y: rt.union([rt.number, rt.null]), +}); + +export const APMTpmBucketsRT = rt.type({ + key: rt.string, + dataPoints: rt.array(APMDataPointRT), +}); + +export const APMChartResponseRT = rt.type({ + apmTimeseries: rt.intersection([ + rt.type({ + responseTimes: rt.type({ + avg: rt.array(APMDataPointRT), + p95: rt.array(APMDataPointRT), + p99: rt.array(APMDataPointRT), + }), + tpmBuckets: rt.array(APMTpmBucketsRT), + }), + rt.partial({ + overallAvgDuration: rt.union([rt.number, rt.null]), + }), + ]), +}); + +export const APMServiceResponseRT = rt.type({ + hasHistoricalData: rt.boolean, + hasLegacyData: rt.boolean, + items: rt.array( + rt.type({ + agentName: rt.string, + avgResponseTime: rt.number, + environments: rt.array(rt.string), + errorsPerMinute: rt.number, + serviceName: rt.string, + transactionsPerMinute: rt.number, + }) + ), +}); + +export type InfraApmMetricsRequest = rt.TypeOf; + +export type InfraApmMetricsRequestWrapped = InfraWrappableRequest; + +export type InfraApmMetrics = rt.TypeOf; + +export type InfraApmMetricsService = rt.TypeOf; + +export type InfraApmMetricsSeries = rt.TypeOf; + +export type InfraApmMetricsDataPoint = rt.TypeOf; + +export type InfraApmMetricsTransactionType = rt.TypeOf; + +export type InfraApmMetricsDataSet = rt.TypeOf; + +export type APMDataPoint = rt.TypeOf; + +export type APMTpmBuckets = rt.TypeOf; + +export type APMChartResponse = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/common/http_api/common.ts b/x-pack/legacy/plugins/infra/common/http_api/common.ts new file mode 100644 index 0000000000000..72690cef4de78 --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/http_api/common.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as rt from 'io-ts'; + +export const InfraNodeTypeRT = rt.keyof({ + host: null, + container: null, + pod: null, +}); + +export type InfraNodeType = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/common/http_api/index.ts b/x-pack/legacy/plugins/infra/common/http_api/index.ts index 5278ae6c249c8..02cf9d0436654 100644 --- a/x-pack/legacy/plugins/infra/common/http_api/index.ts +++ b/x-pack/legacy/plugins/infra/common/http_api/index.ts @@ -6,3 +6,4 @@ export * from './log_analysis'; export * from './metadata_api'; +export * from './apm_metrics_api'; diff --git a/x-pack/legacy/plugins/infra/common/http_api/metadata_api.ts b/x-pack/legacy/plugins/infra/common/http_api/metadata_api.ts index 5b9389a073002..7c6d60457e53a 100644 --- a/x-pack/legacy/plugins/infra/common/http_api/metadata_api.ts +++ b/x-pack/legacy/plugins/infra/common/http_api/metadata_api.ts @@ -6,16 +6,11 @@ import * as rt from 'io-ts'; import { InfraWrappableRequest } from '../../server/lib/adapters/framework'; - -export const InfraMetadataNodeTypeRT = rt.keyof({ - host: null, - pod: null, - container: null, -}); +import { InfraNodeTypeRT } from './common'; export const InfraMetadataRequestRT = rt.type({ nodeId: rt.string, - nodeType: InfraMetadataNodeTypeRT, + nodeType: InfraNodeTypeRT, sourceId: rt.string, }); @@ -98,5 +93,3 @@ export type InfraMetadataMachine = rt.TypeOf; export type InfraMetadataHost = rt.TypeOf; export type InfraMetadataOS = rt.TypeOf; - -export type InfraMetadataNodeType = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_id_field_name.ts b/x-pack/legacy/plugins/infra/common/utils/get_apm_field_name.ts similarity index 51% rename from x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_id_field_name.ts rename to x-pack/legacy/plugins/infra/common/utils/get_apm_field_name.ts index 5f6bdd30fa2b8..9c1798bfaee0b 100644 --- a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_id_field_name.ts +++ b/x-pack/legacy/plugins/infra/common/utils/get_apm_field_name.ts @@ -4,9 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { InfraSourceConfiguration } from '../../../lib/sources'; +import { InfraSourceConfiguration } from '../graphql/types'; +import { InfraNodeType } from '../http_api/common'; -export const getIdFieldName = (sourceConfiguration: InfraSourceConfiguration, nodeType: string) => { +export const getApmFieldName = ( + sourceConfiguration: InfraSourceConfiguration, + nodeType: InfraNodeType +) => { + return nodeType === 'host' ? 'host.hostname' : getIdFieldName(sourceConfiguration, nodeType); +}; + +export const getIdFieldName = ( + sourceConfiguration: InfraSourceConfiguration, + nodeType: InfraNodeType +) => { switch (nodeType) { case 'host': return sourceConfiguration.fields.host; diff --git a/x-pack/legacy/plugins/infra/package.json b/x-pack/legacy/plugins/infra/package.json index 63812bb2da513..4d2b6246830a1 100644 --- a/x-pack/legacy/plugins/infra/package.json +++ b/x-pack/legacy/plugins/infra/package.json @@ -8,7 +8,6 @@ "build-graphql-types": "node scripts/generate_types_from_graphql.js" }, "devDependencies": { - "@types/boom": "7.2.1", "@types/lodash": "^4.14.110" }, "dependencies": { @@ -16,4 +15,4 @@ "boom": "7.3.0", "lodash": "^4.17.15" } -} \ No newline at end of file +} diff --git a/x-pack/legacy/plugins/infra/public/components/metrics/index.tsx b/x-pack/legacy/plugins/infra/public/components/metrics/index.tsx index 2bab642bb337c..6e26266e92762 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics/index.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics/index.tsx @@ -6,37 +6,34 @@ import { EuiPageContentBody, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { InfraMetricData } from '../../graphql/types'; +import { InfraTimerangeInput, InfraNodeType } from '../../graphql/types'; import { InfraMetricLayout, InfraMetricLayoutSection } from '../../pages/metrics/layouts/types'; import { NoData } from '../empty_states'; import { InfraLoadingPanel } from '../loading'; import { Section } from './section'; +import { InfraMetricCombinedData } from '../../containers/metrics/with_metrics'; +import { SourceConfiguration } from '../../utils/source_configuration'; import { MetricsTimeInput } from '../../containers/metrics/with_metrics_time'; interface Props { - metrics: InfraMetricData[]; + metrics: InfraMetricCombinedData[]; layouts: InfraMetricLayout[]; loading: boolean; refetch: () => void; - nodeId: string; label: string; onChangeRangeTime?: (time: MetricsTimeInput) => void; isLiveStreaming?: boolean; stopLiveStreaming?: () => void; + nodeId: string; + nodeType: InfraNodeType; + sourceConfiguration: SourceConfiguration; + timeRange: InfraTimerangeInput; } -interface State { - crosshairValue: number | null; -} - -export const Metrics = class extends React.PureComponent { +export const Metrics = class extends React.PureComponent { public static displayName = 'Metrics'; - public readonly state = { - crosshairValue: null, - }; public render() { if (this.props.loading) { @@ -79,15 +76,7 @@ export const Metrics = class extends React.PureComponent { -

- -

+

{layout.label}

{layout.sections.map(this.renderSection(layout))} @@ -96,30 +85,19 @@ export const Metrics = class extends React.PureComponent { }; private renderSection = (layout: InfraMetricLayout) => (section: InfraMetricLayoutSection) => { - let sectionProps = {}; - if (section.type === 'chart') { - const { onChangeRangeTime, isLiveStreaming, stopLiveStreaming } = this.props; - sectionProps = { - onChangeRangeTime, - isLiveStreaming, - stopLiveStreaming, - crosshairValue: this.state.crosshairValue, - onCrosshairUpdate: this.onCrosshairUpdate, - }; - } return (
); }; - - private onCrosshairUpdate = (crosshairValue: number) => { - this.setState({ - crosshairValue, - }); - }; }; diff --git a/x-pack/legacy/plugins/infra/public/components/metrics/section.tsx b/x-pack/legacy/plugins/infra/public/components/metrics/section.tsx index 69981086637b6..907eff178087a 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics/section.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics/section.tsx @@ -5,19 +5,25 @@ */ import React from 'react'; -import { InfraMetricData } from '../../graphql/types'; +import { InfraTimerangeInput, InfraNodeType } from '../../graphql/types'; import { InfraMetricLayoutSection } from '../../pages/metrics/layouts/types'; import { sections } from './sections'; +import { InfraMetricCombinedData } from '../../containers/metrics/with_metrics'; +import { SourceConfiguration } from '../../utils/source_configuration'; import { MetricsTimeInput } from '../../containers/metrics/with_metrics_time'; interface Props { section: InfraMetricLayoutSection; - metrics: InfraMetricData[]; + metrics: InfraMetricCombinedData[]; onChangeRangeTime?: (time: MetricsTimeInput) => void; crosshairValue?: number; onCrosshairUpdate?: (crosshairValue: number) => void; isLiveStreaming?: boolean; stopLiveStreaming?: () => void; + nodeId: string; + nodeType: InfraNodeType; + sourceConfiguration: SourceConfiguration; + timeRange: InfraTimerangeInput; } export class Section extends React.PureComponent { @@ -27,16 +33,24 @@ export class Section extends React.PureComponent { return null; } let sectionProps = {}; - if (this.props.section.type === 'chart') { + if (['apm', 'chart'].includes(this.props.section.type)) { sectionProps = { onChangeRangeTime: this.props.onChangeRangeTime, - crosshairValue: this.props.crosshairValue, - onCrosshairUpdate: this.props.onCrosshairUpdate, isLiveStreaming: this.props.isLiveStreaming, stopLiveStreaming: this.props.stopLiveStreaming, }; } const Component = sections[this.props.section.type]; - return ; + return ( + + ); } } diff --git a/x-pack/legacy/plugins/infra/public/components/metrics/sections/apm_chart.tsx b/x-pack/legacy/plugins/infra/public/components/metrics/sections/apm_chart.tsx new file mode 100644 index 0000000000000..0651e19dd3d8f --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/components/metrics/sections/apm_chart.tsx @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + Axis, + Chart, + getAxisId, + niceTimeFormatter, + Position, + Settings, + TooltipValue, +} from '@elastic/charts'; +import moment from 'moment'; +import React, { useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useKibanaUiSetting } from '../../../utils/use_kibana_ui_setting'; +import { getChartTheme } from '../../metrics_explorer/helpers/get_chart_theme'; +import { SeriesChart } from './series_chart'; +import { getFormatter, getMaxMinTimestamp, seriesHasLessThen2DataPoints } from './helpers'; +import { InfraApmMetricsDataSet } from '../../../../common/http_api'; +import { InfraFormatterType } from '../../../lib/lib'; +import { + InfraMetricLayoutSection, + InfraMetricLayoutVisualizationType, +} from '../../../pages/metrics/layouts/types'; +import { ErrorMessage } from './error_message'; +import { InfraTimerangeInput } from '../../../graphql/types'; + +interface Props { + section: InfraMetricLayoutSection; + dataSet?: InfraApmMetricsDataSet | undefined; + formatterTemplate?: string; + transformDataPoint?: (value: number) => number; + onChangeRangeTime?: (time: InfraTimerangeInput) => void; + isLiveStreaming?: boolean; + stopLiveStreaming?: () => void; +} + +export const ApmChart = ({ + dataSet, + section, + formatterTemplate, + transformDataPoint = noopTransformer, + onChangeRangeTime, + isLiveStreaming, + stopLiveStreaming, +}: Props) => { + if (!dataSet) { + return ( + + ); + } + if (dataSet.series.some(seriesHasLessThen2DataPoints)) { + return ( + + ); + } + + const [dateFormat] = useKibanaUiSetting('dateFormat'); + const tooltipProps = { + headerFormatter: useCallback( + (data: TooltipValue) => moment(data.value).format(dateFormat || 'Y-MM-DD HH:mm:ss.SSS'), + [dateFormat] + ), + }; + const valueFormatter = getFormatter(InfraFormatterType.number, formatterTemplate || '{{value}}'); + const valueFormatterWithTransorm = useCallback( + (value: number) => valueFormatter(transformDataPoint(value)), + [formatterTemplate, transformDataPoint] + ); + const dateFormatter = useCallback(niceTimeFormatter(getMaxMinTimestamp(dataSet)), [dataSet]); + const handleTimeChange = useCallback( + (from: number, to: number) => { + if (onChangeRangeTime) { + if (isLiveStreaming && stopLiveStreaming) { + stopLiveStreaming(); + } + onChangeRangeTime({ + from, + to, + interval: '>=1m', + }); + } + }, + [onChangeRangeTime, isLiveStreaming, stopLiveStreaming] + ); + const [minTimestamp, maxTimestamp] = getMaxMinTimestamp(dataSet); + const timestampDomain = { min: minTimestamp, max: maxTimestamp }; + + return ( + + + + {dataSet && + dataSet.series.map(series => ( + + ))} + + + ); +}; + +const noopTransformer = (value: number) => value; diff --git a/x-pack/legacy/plugins/infra/public/components/metrics/sections/apm_section.tsx b/x-pack/legacy/plugins/infra/public/components/metrics/sections/apm_section.tsx new file mode 100644 index 0000000000000..91a724d1c065c --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/components/metrics/sections/apm_section.tsx @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiPageContentBody, EuiTitle, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { InfraMetricLayoutSection } from '../../../pages/metrics/layouts/types'; +import { InfraTimerangeInput, InfraNodeType } from '../../../graphql/types'; +import { isInfraApmMetrics } from '../../../utils/is_infra_metric_data'; +import { InfraMetricCombinedData } from '../../../containers/metrics/with_metrics'; +import { createFormatter } from '../../../utils/formatters'; +import { InfraFormatterType } from '../../../lib/lib'; +import { MetricSummary, MetricSummaryItem } from './metric_summary'; +import { ApmChart } from './apm_chart'; +import { SourceConfiguration } from '../../../utils/source_configuration'; +import { createAPMServiceLink } from './helpers/create_apm_service_link'; +import { euiStyled } from '../../../../../../common/eui_styled_components'; + +interface Props { + section: InfraMetricLayoutSection; + metric: InfraMetricCombinedData; + onChangeRangeTime?: (time: InfraTimerangeInput) => void; + isLiveStreaming?: boolean; + stopLiveStreaming?: () => void; + nodeId: string; + nodeType: InfraNodeType; + sourceConfiguration: SourceConfiguration; + timeRange: InfraTimerangeInput; +} + +export const ApmSection = ({ + metric, + section, + onChangeRangeTime, + isLiveStreaming, + stopLiveStreaming, + nodeId, + nodeType, + sourceConfiguration, + timeRange, +}: Props) => { + if (!isInfraApmMetrics(metric)) { + throw new Error('ApmSection requires InfraApmMetrics'); + } + const numberFormatter = createFormatter(InfraFormatterType.number); + return ( + + {metric.services.map(service => ( + + + + +

+ {i18n.translate('xpack.infra.apmSection.serviceLavel', { + defaultMessage: 'Service: {id}', + values: { id: service.id }, + })} +

+
+
+ + + {i18n.translate('xpack.infra.apmSection.viewInApmLabel', { + defaultMessage: 'View in APM', + })} + + +
+ + + + + + + {['request', 'job'].map(type => { + if (service.dataSets.some(d => d.type === type)) { + const transPerMinute = service.dataSets.find( + d => d.type === type && d.id === 'transactionsPerMinute' + ); + const responseTimes = service.dataSets.find( + d => d.type === type && d.id === 'responseTimes' + ); + return ( + + +

+ {i18n.translate( + 'xpack.infra.apmSection.metricSummary.transactionDurationLabel', + { defaultMessage: 'Transaction duration ({type})', values: { type } } + )} +

+
+ + value / 1000} + isLiveStreaming={isLiveStreaming} + stopLiveStreaming={stopLiveStreaming} + onChangeRangeTime={onChangeRangeTime} + /> + + +

+ {i18n.translate( + 'xpack.infra.apmSection.metricSummary.requestPerMinuteLabel', + { defaultMessage: 'Request per minute ({type})', values: { type } } + )} +

+
+ + + +
+ ); + } + return null; + })} +
+ ))} +
+ ); +}; + +const ChartContainer = euiStyled.div` +height: 250px; +margin-bottom: ${params => params.theme.eui.euiSizeM}; +`; diff --git a/x-pack/legacy/plugins/infra/public/components/metrics/sections/chart_section.tsx b/x-pack/legacy/plugins/infra/public/components/metrics/sections/chart_section.tsx index 4c0e0a7d50167..7fbd05a0003c1 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics/sections/chart_section.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics/sections/chart_section.tsx @@ -19,7 +19,7 @@ import { } from '@elastic/charts'; import { EuiPageContentBody, EuiTitle } from '@elastic/eui'; import { InfraMetricLayoutSection } from '../../../pages/metrics/layouts/types'; -import { InfraMetricData } from '../../../graphql/types'; +import { InfraTimerangeInput, InfraNodeType } from '../../../graphql/types'; import { getChartTheme } from '../../metrics_explorer/helpers/get_chart_theme'; import { InfraFormatterType } from '../../../lib/lib'; import { SeriesChart } from './series_chart'; @@ -33,14 +33,21 @@ import { } from './helpers'; import { ErrorMessage } from './error_message'; import { useKibanaUiSetting } from '../../../utils/use_kibana_ui_setting'; +import { InfraMetricCombinedData } from '../../../containers/metrics/with_metrics'; +import { isInfraMetricData } from '../../../utils/is_infra_apm_metrics'; +import { SourceConfiguration } from '../../../utils/source_configuration'; import { MetricsTimeInput } from '../../../containers/metrics/with_metrics_time'; interface Props { section: InfraMetricLayoutSection; - metric: InfraMetricData; + metric: InfraMetricCombinedData; onChangeRangeTime?: (time: MetricsTimeInput) => void; isLiveStreaming?: boolean; stopLiveStreaming?: () => void; + nodeId: string; + nodeType: InfraNodeType; + sourceConfiguration: SourceConfiguration; + timeRange: InfraTimerangeInput; } export const ChartSection = ({ @@ -50,6 +57,9 @@ export const ChartSection = ({ stopLiveStreaming, isLiveStreaming, }: Props) => { + if (!isInfraMetricData(metric)) { + throw new Error('ChartSection only accepts InfraMetricData'); + } const { visConfig } = section; const [dateFormat] = useKibanaUiSetting('dateFormat'); const formatter = get(visConfig, 'formatter', InfraFormatterType.number); diff --git a/x-pack/legacy/plugins/infra/public/components/metrics/sections/gauges_section.tsx b/x-pack/legacy/plugins/infra/public/components/metrics/sections/gauges_section.tsx index 2c49d778d55b7..0041b546471a3 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics/sections/gauges_section.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics/sections/gauges_section.tsx @@ -17,14 +17,21 @@ import { get, last, max } from 'lodash'; import React, { ReactText } from 'react'; import euiStyled from '../../../../../../common/eui_styled_components'; -import { InfraMetricData } from '../../../graphql/types'; import { InfraFormatterType } from '../../../lib/lib'; import { InfraMetricLayoutSection } from '../../../pages/metrics/layouts/types'; import { createFormatter } from '../../../utils/formatters'; +import { isInfraMetricData } from '../../../utils/is_infra_apm_metrics'; +import { InfraMetricCombinedData } from '../../../containers/metrics/with_metrics'; +import { InfraNodeType, InfraTimerangeInput } from '../../../graphql/types'; +import { SourceConfiguration } from '../../../utils/source_configuration'; interface Props { section: InfraMetricLayoutSection; - metric: InfraMetricData; + metric: InfraMetricCombinedData; + nodeId: string; + nodeType: InfraNodeType; + sourceConfiguration: SourceConfiguration; + timeRange: InfraTimerangeInput; } const getFormatter = (section: InfraMetricLayoutSection, seriesId: string) => (val: ReactText) => { @@ -49,6 +56,9 @@ const getFormatter = (section: InfraMetricLayoutSection, seriesId: string) => (v export class GaugesSection extends React.PureComponent { public render() { const { metric, section } = this.props; + if (!isInfraMetricData(metric)) { + throw new Error('GaugesSection only accepts InfraMetricData'); + } return ( diff --git a/x-pack/legacy/plugins/infra/public/components/metrics/sections/helpers/create_apm_service_link.ts b/x-pack/legacy/plugins/infra/public/components/metrics/sections/helpers/create_apm_service_link.ts new file mode 100644 index 0000000000000..a4b228832c864 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/components/metrics/sections/helpers/create_apm_service_link.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +import { InfraNodeType, InfraTimerangeInput } from '../../../../graphql/types'; +import { SourceConfiguration } from '../../../../utils/source_configuration'; +import { getApmFieldName } from '../../../../../common/utils/get_apm_field_name'; + +export const createAPMServiceLink = ( + serviceName: string, + nodeId: string, + nodeType: InfraNodeType, + sourceConfiguration: SourceConfiguration, + timeRange: InfraTimerangeInput +) => { + const nodeField = getApmFieldName(sourceConfiguration, nodeType); + const from = moment(timeRange.from).toISOString(); + const to = moment(timeRange.to).toISOString(); + return `../app/apm#/services/${serviceName}/transactions?rangeFrom=${from}&rangeTo=${to}&transactionType=request&kuery=${encodeURIComponent( + `${nodeField}:"${nodeId}"` + )}`; +}; diff --git a/x-pack/legacy/plugins/infra/public/components/metrics/sections/helpers/index.ts b/x-pack/legacy/plugins/infra/public/components/metrics/sections/helpers/index.ts index 8db30f6b4c415..2e47fde5acc0e 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics/sections/helpers/index.ts +++ b/x-pack/legacy/plugins/infra/public/components/metrics/sections/helpers/index.ts @@ -9,12 +9,24 @@ import Color from 'color'; import { get, first, last, min, max } from 'lodash'; import { InfraFormatterType } from '../../../../lib/lib'; import { createFormatter } from '../../../../utils/formatters'; -import { InfraDataSeries, InfraMetricData } from '../../../../graphql/types'; import { InfraMetricLayoutVisualizationType, InfraMetricLayoutSection, } from '../../../../pages/metrics/layouts/types'; +interface DataPoint { + timestamp: number; + value?: number | null; +} + +interface Series { + data: DataPoint[]; +} + +interface ItemWithSeries { + series: Series[]; +} + /** * Returns a formatter */ @@ -24,21 +36,21 @@ export const getFormatter = (formatter: InfraFormatterType, template: string) => /** * Does a series have more then two points? */ -export const seriesHasLessThen2DataPoints = (series: InfraDataSeries): boolean => { +export const seriesHasLessThen2DataPoints = (series: Series): boolean => { return series.data.length < 2; }; /** * Returns the minimum and maximum timestamp for a metric */ -export const getMaxMinTimestamp = (metric: InfraMetricData): [number, number] => { - if (metric.series.some(seriesHasLessThen2DataPoints)) { +export const getMaxMinTimestamp = (item: ItemWithSeries): [number, number] => { + if (item.series.some(seriesHasLessThen2DataPoints)) { return [0, 0]; } - const values = metric.series.reduce( - (acc, item) => { - const firstRow = first(item.data); - const lastRow = last(item.data); + const values = item.series.reduce( + (acc, series) => { + const firstRow = first(series.data); + const lastRow = last(series.data); return acc.concat([ (firstRow && firstRow.timestamp) || 0, (lastRow && lastRow.timestamp) || 0, diff --git a/x-pack/legacy/plugins/infra/public/components/metrics/sections/index.ts b/x-pack/legacy/plugins/infra/public/components/metrics/sections/index.ts index f5a8fcfed6dd6..6902817aa45c7 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics/sections/index.ts +++ b/x-pack/legacy/plugins/infra/public/components/metrics/sections/index.ts @@ -5,11 +5,12 @@ */ import { InfraMetricLayoutSectionType } from '../../../pages/metrics/layouts/types'; -// import { ChartSection } from './chart_section'; import { GaugesSection } from './gauges_section'; import { ChartSection } from './chart_section'; +import { ApmSection } from './apm_section'; export const sections = { [InfraMetricLayoutSectionType.chart]: ChartSection, [InfraMetricLayoutSectionType.gauges]: GaugesSection, + [InfraMetricLayoutSectionType.apm]: ApmSection, }; diff --git a/x-pack/legacy/plugins/infra/public/components/metrics/sections/metric_summary.tsx b/x-pack/legacy/plugins/infra/public/components/metrics/sections/metric_summary.tsx new file mode 100644 index 0000000000000..dfee20117546a --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/components/metrics/sections/metric_summary.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui'; + +import React from 'react'; +import euiStyled from '../../../../../../common/eui_styled_components'; + +interface Props { + label: string; + value: string; +} + +export const MetricSummaryItem = ({ label, value }: Props) => { + return ( + + {label} + +

{value}

+
+
+ ); +}; + +export const MetricSummary = euiStyled.div` + display: flex; + flex-flow: row wrap; + border-top: 1px solid #DDD; + border-bottom: 1px solid #DDD; + margin: 0.8rem 0; +`; diff --git a/x-pack/legacy/plugins/infra/public/components/metrics/sections/series_chart.tsx b/x-pack/legacy/plugins/infra/public/components/metrics/sections/series_chart.tsx index 8a29729f7c9c5..1484b7600e6a4 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics/sections/series_chart.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics/sections/series_chart.tsx @@ -22,10 +22,11 @@ import { InfraDataSeries } from '../../../graphql/types'; interface Props { id: string; name: string; - color: string; + color?: string | undefined; series: InfraDataSeries; type: InfraMetricLayoutVisualizationType; - stack: boolean | undefined; + stack?: boolean | undefined; + ignoreGaps?: boolean | undefined; } export const SeriesChart = (props: Props) => { @@ -35,7 +36,7 @@ export const SeriesChart = (props: Props) => { return ; }; -export const AreaChart = ({ id, color, series, name, type, stack }: Props) => { +export const AreaChart = ({ id, color, series, name, type, stack, ignoreGaps }: Props) => { const style: RecursivePartial = { area: { opacity: 1, @@ -57,7 +58,8 @@ export const AreaChart = ({ id, color, series, name, type, stack }: Props) => { specId: getSpecId(id), }; const customColors: CustomSeriesColorsMap = new Map(); - customColors.set(colors, color); + customColors.set(colors, color || ''); + const data = ignoreGaps ? series.data.filter(d => d.value) : series.data; return ( { yScaleType={ScaleType.Linear} xAccessor="timestamp" yAccessors={['value']} - data={series.data} + data={data} areaSeriesStyle={style} - customSeriesColors={customColors} + customSeriesColors={color ? customColors : void 0} stackAccessors={stack ? ['timestamp'] : void 0} /> ); }; -export const BarChart = ({ id, color, series, name, type, stack }: Props) => { +export const BarChart = ({ id, color, series, name, stack, ignoreGaps }: Props) => { const style: RecursivePartial = { rectBorder: { - stroke: color, - strokeWidth: 1, - visible: true, + visible: false, + strokeWidth: 2, + stroke: color || '', }, rect: { opacity: 1, @@ -90,7 +92,8 @@ export const BarChart = ({ id, color, series, name, type, stack }: Props) => { specId: getSpecId(id), }; const customColors: CustomSeriesColorsMap = new Map(); - customColors.set(colors, color); + customColors.set(colors, color || ''); + const data = ignoreGaps ? series.data.filter(d => d.value) : series.data; return ( { yScaleType={ScaleType.Linear} xAccessor="timestamp" yAccessors={['value']} - data={series.data} + data={data} barSeriesStyle={style} - customSeriesColors={customColors} + customSeriesColors={color ? customColors : void 0} stackAccessors={stack ? ['timestamp'] : void 0} /> ); diff --git a/x-pack/legacy/plugins/infra/public/components/metrics/side_nav.tsx b/x-pack/legacy/plugins/infra/public/components/metrics/side_nav.tsx index 106d22db169ad..40eeee6024786 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics/side_nav.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics/side_nav.tsx @@ -9,10 +9,16 @@ import { EuiHideFor, EuiPageSideBar, EuiShowFor, EuiSideNav } from '@elastic/eui import React from 'react'; import euiStyled from '../../../../../common/eui_styled_components'; -import { InfraMetricLayout, InfraMetricLayoutSection } from '../../pages/metrics/layouts/types'; +import { + InfraMetricLayout, + InfraMetricLayoutSection, + InfraMetricSideNav, +} from '../../pages/metrics/layouts/types'; +import { InfraMetricCombinedData } from '../../containers/metrics/with_metrics'; interface Props { layouts: InfraMetricLayout[]; + metrics: InfraMetricCombinedData[]; loading: boolean; nodeName: string; handleClick: (section: InfraMetricLayoutSection) => () => void; @@ -28,18 +34,26 @@ export const MetricsSideNav = class extends React.PureComponent { public render() { let content; let mobileContent; + const DEFAULT_MAP_NAV_ITEMS = (item: InfraMetricLayout) => { + return { + name: item.label, + id: item.id, + items: item.sections.map(section => ({ + id: section.id, + name: section.label, + onClick: this.props.handleClick(section), + })), + }; + }; if (!this.props.loading) { - const entries = this.props.layouts.map(item => { - return { - name: item.label, - id: item.id, - items: item.sections.map(section => ({ - id: section.id, - name: section.label, - onClick: this.props.handleClick(section), - })), - }; - }); + const entries = this.props.layouts + .map(item => { + if (item.mapNavItem) { + return item.mapNavItem(item, this.props.metrics); + } + return DEFAULT_MAP_NAV_ITEMS(item); + }) + .filter(e => e) as InfraMetricSideNav[]; content = ; mobileContent = ( = metadata - .filter(data => data && data.source === 'metrics') + .filter(data => data && ['apm', 'metrics'].includes(data.source)) .map(data => data && data.name); // After filtering out sections that can't be displayed, a layout may end up empty and can be removed. diff --git a/x-pack/legacy/plugins/infra/public/containers/metrics/use_apm_metrics.ts b/x-pack/legacy/plugins/infra/public/containers/metrics/use_apm_metrics.ts new file mode 100644 index 0000000000000..3b82bbbe2b92a --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/containers/metrics/use_apm_metrics.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo, useEffect } from 'react'; +import stringify from 'json-stable-stringify'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { useHTTPRequest } from '../../hooks/use_http_request'; +import { InfraNodeType, InfraTimerangeInput } from '../../graphql/types'; +import { InfraApmMetricsRT } from '../../../common/http_api/apm_metrics_api'; +import { throwErrors } from '../../../common/runtime_types'; + +export const useApmMetrics = ( + sourceId: string, + nodeId: string, + nodeType: InfraNodeType, + timeRange: InfraTimerangeInput +) => { + const body = useMemo( + () => ({ + sourceId, + nodeId, + nodeType, + timeRange: { min: timeRange.from, max: timeRange.to }, + }), + [sourceId, nodeId, nodeType, timeRange] + ); + + const decode = (subject: any) => + pipe( + InfraApmMetricsRT.decode(subject), + fold(throwErrors(message => new Error(`APM Metrics Request Failed: ${message}`)), identity) + ); + + const { response, loading, error, makeRequest } = useHTTPRequest( + '/api/infra/apm_metrics', + 'POST', + stringify(body), + decode + ); + + useEffect(() => { + (async () => { + await makeRequest(); + })(); + }, [makeRequest]); + + return { metrics: response, loading, error: error || void 0, makeRequest }; +}; diff --git a/x-pack/legacy/plugins/infra/public/containers/metrics/with_metrics.tsx b/x-pack/legacy/plugins/infra/public/containers/metrics/with_metrics.tsx index da5baab030828..b81d728c1bcfa 100644 --- a/x-pack/legacy/plugins/infra/public/containers/metrics/with_metrics.tsx +++ b/x-pack/legacy/plugins/infra/public/containers/metrics/with_metrics.tsx @@ -15,10 +15,13 @@ import { } from '../../graphql/types'; import { InfraMetricLayout } from '../../pages/metrics/layouts/types'; import { metricsQuery } from './metrics.gql_query'; +import { useApmMetrics } from './use_apm_metrics'; +import { InfraApmMetrics } from '../../../common/http_api'; +import { KFetchError } from '../../../../../../../src/legacy/ui/public/kfetch/kfetch_error'; interface WithMetricsArgs { - metrics: InfraMetricData[]; - error?: ApolloError | undefined; + metrics: InfraMetricCombinedData[]; + error?: KFetchError | ApolloError | undefined; loading: boolean; refetch: () => void; } @@ -33,6 +36,8 @@ interface WithMetricsProps { timerange: InfraTimerangeInput; } +export type InfraMetricCombinedData = InfraMetricData | InfraApmMetrics; + export const WithMetrics = ({ children, layouts, @@ -49,6 +54,17 @@ export const WithMetrics = ({ [] as InfraMetric[] ); + // only make this request if apmMetrics is included in the list + // of metrics from the filtered layout. + const apmMetrics = metrics.includes(InfraMetric.apmMetrics) + ? useApmMetrics(sourceId, nodeId, nodeType, timerange) + : { + metrics: null, + loading: false, + error: undefined, + makeRequest: () => void 0, + }; + return ( query={metricsQuery} @@ -56,7 +72,11 @@ export const WithMetrics = ({ notifyOnNetworkStatusChange variables={{ sourceId, - metrics, + // Need to filter out the apmMetrics id since that is being handled + // by the useApmMetrics hook. This could have been added to the GraphQL + // endpoint but would have required signifigant overhead with managing + // the types due to the shape of the APM metric data + metrics: metrics.filter(m => m !== InfraMetric.apmMetrics), nodeType, nodeId, cloudId, @@ -65,10 +85,16 @@ export const WithMetrics = ({ > {({ data, error, loading, refetch }) => { return children({ - metrics: filterOnlyInfraMetricData(data && data.source && data.source.metrics), - error, - loading, - refetch, + metrics: [ + ...filterOnlyInfraMetricData(data && data.source && data.source.metrics), + ...(apmMetrics.metrics ? [apmMetrics.metrics] : []), + ], + error: error || apmMetrics.error, + loading: loading && apmMetrics.loading, + refetch: () => { + refetch(); + apmMetrics.makeRequest(); + }, }); }} diff --git a/x-pack/legacy/plugins/infra/public/graphql/introspection.json b/x-pack/legacy/plugins/infra/public/graphql/introspection.json index d1c1c197d160f..21b224ff0e6e2 100644 --- a/x-pack/legacy/plugins/infra/public/graphql/introspection.json +++ b/x-pack/legacy/plugins/infra/public/graphql/introspection.json @@ -2525,6 +2525,12 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "apmMetrics", + "description": "", + "isDeprecated": false, + "deprecationReason": null + }, { "name": "awsOverview", "description": "", diff --git a/x-pack/legacy/plugins/infra/public/graphql/types.ts b/x-pack/legacy/plugins/infra/public/graphql/types.ts index 89d5608ad7c29..ab6803cefea98 100644 --- a/x-pack/legacy/plugins/infra/public/graphql/types.ts +++ b/x-pack/legacy/plugins/infra/public/graphql/types.ts @@ -590,6 +590,7 @@ export enum InfraMetric { nginxRequestRate = 'nginxRequestRate', nginxActiveConnections = 'nginxActiveConnections', nginxRequestsPerConnection = 'nginxRequestsPerConnection', + apmMetrics = 'apmMetrics', awsOverview = 'awsOverview', awsCpuUtilization = 'awsCpuUtilization', awsNetworkBytes = 'awsNetworkBytes', diff --git a/x-pack/legacy/plugins/infra/public/hooks/use_http_request.tsx b/x-pack/legacy/plugins/infra/public/hooks/use_http_request.tsx index 9ed72e656c45a..2d79183f476e2 100644 --- a/x-pack/legacy/plugins/infra/public/hooks/use_http_request.tsx +++ b/x-pack/legacy/plugins/infra/public/hooks/use_http_request.tsx @@ -4,66 +4,58 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { kfetch } from 'ui/kfetch'; import { toastNotifications } from 'ui/notify'; import { i18n } from '@kbn/i18n'; import { idx } from '@kbn/elastic-idx/target'; import { KFetchError } from 'ui/kfetch/kfetch_error'; -import { useTrackedPromise } from '../utils/use_tracked_promise'; export function useHTTPRequest( pathname: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD', body?: string, decode: (response: any) => Response = response => response ) { + const [loading, setLoading] = useState(false); const [response, setResponse] = useState(null); const [error, setError] = useState(null); - const [request, makeRequest] = useTrackedPromise( - { - cancelPreviousOn: 'resolution', - createPromise: () => - kfetch({ - method, - pathname, - body, - }), - onResolve: resp => setResponse(decode(resp)), - onReject: (e: unknown) => { - const err = e as KFetchError; - setError(err); - toastNotifications.addWarning({ - title: i18n.translate('xpack.infra.useHTTPRequest.error.title', { - defaultMessage: `Error while fetching resource`, - }), - text: ( -
-
- {i18n.translate('xpack.infra.useHTTPRequest.error.status', { - defaultMessage: `Error`, - })} -
- {idx(err.res, r => r.statusText)} ({idx(err.res, r => r.status)}) -
- {i18n.translate('xpack.infra.useHTTPRequest.error.url', { - defaultMessage: `URL`, - })} -
- {idx(err.res, r => r.url)} -
- ), - }); - }, - }, - [pathname, body, method] - ); - const loading = useMemo(() => { - if (request.state === 'resolved' && response === null) { - return true; + const makeRequest = useCallback(async () => { + try { + setLoading(true); + const resp = await kfetch({ + method, + pathname, + body, + }); + setResponse(decode(resp)); + setLoading(false); + } catch (err) { + setLoading(false); + setError(err); + toastNotifications.addWarning({ + title: i18n.translate('xpack.infra.useHTTPRequest.error.title', { + defaultMessage: `Error while fetching resource`, + }), + text: ( +
+
+ {i18n.translate('xpack.infra.useHTTPRequest.error.status', { + defaultMessage: `Error`, + })} +
+ {idx(err.res, r => r.statusText)} ({idx(err.res, r => r.status)}) +
+ {i18n.translate('xpack.infra.useHTTPRequest.error.url', { + defaultMessage: `URL`, + })} +
+ {idx(err.res, r => r.url)} +
+ ), + }); } - return request.state === 'pending'; - }, [request.state, response]); + }, [method, pathname, body]); return { response, diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/index.tsx b/x-pack/legacy/plugins/infra/public/pages/metrics/index.tsx index c22db22c232da..a1c65d63d0ea8 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/index.tsx @@ -18,6 +18,7 @@ import { GraphQLFormattedError } from 'graphql'; import React, { useCallback, useContext } from 'react'; import { UICapabilities } from 'ui/capabilities'; import { injectUICapabilities } from 'ui/capabilities/react'; +import { ApolloError } from 'apollo-client'; import euiStyled, { EuiTheme, withTheme } from '../../../../../common/eui_styled_components'; import { InfraMetricsErrorCodes } from '../../../common/errors'; import { AutoSizer } from '../../components/auto_sizer'; @@ -43,6 +44,10 @@ import { Source } from '../../containers/source'; import { InfraLoadingPanel } from '../../components/loading'; import { NodeDetails } from '../../components/metrics/node_details'; +const isApolloError = (subject: any): subject is ApolloError => { + return subject.graphQLErrors != null; +}; + const DetailPageContent = euiStyled(PageContent)` overflow: auto; background-color: ${props => props.theme.eui.euiColorLightestShade}; @@ -81,7 +86,7 @@ export const MetricDetail = withMetricPageProviders( /> ); } - const { sourceId } = useContext(Source.Context); + const { source, sourceId } = useContext(Source.Context); const layouts = layoutCreator(theme); const { name, filteredLayouts, loading: metadataLoading, cloudId, metadata } = useMetadata( nodeId, @@ -110,7 +115,7 @@ export const MetricDetail = withMetricPageProviders( [] ); - if (metadataLoading && !filteredLayouts.length) { + if (!source || (metadataLoading && !filteredLayouts.length)) { return ( {({ metrics, error, loading, refetch }) => { if (error) { - const invalidNodeError = error.graphQLErrors.some( - (err: GraphQLFormattedError) => - err.code === InfraMetricsErrorCodes.invalid_node - ); + const invalidNodeError = + isApolloError(error) && + error.graphQLErrors.some( + (err: GraphQLFormattedError) => + err.code === InfraMetricsErrorCodes.invalid_node + ); return ( <> @@ -191,6 +198,7 @@ export const MetricDetail = withMetricPageProviders( loading={metadataLoading} nodeName={name} handleClick={handleClick} + metrics={metrics} /> {({ measureRef, bounds: { width = 0 } }) => { @@ -222,8 +230,11 @@ export const MetricDetail = withMetricPageProviders( 0 && isAutoReloading ? false : loading diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/apm.ts b/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/apm.ts new file mode 100644 index 0000000000000..d8ea3241a1b28 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/apm.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { InfraMetric } from '../../../graphql/types'; +import { InfraMetricLayoutSectionType, InfraMetricLayout } from './types'; +import { InfraMetricCombinedData } from '../../../containers/metrics/with_metrics'; +import { isInfraApmMetrics } from '../../../utils/is_infra_metric_data'; + +export const apmLayoutCreator = (): InfraMetricLayout[] => [ + { + id: 'apm', + label: 'APM', + mapNavItem: (item: InfraMetricLayout, metrics: InfraMetricCombinedData[]) => { + const apmMetrics = metrics.find(m => m.id === InfraMetric.apmMetrics); + if (apmMetrics && isInfraApmMetrics(apmMetrics)) { + return { + name: item.label, + id: item.id, + items: apmMetrics.services.map(service => ({ + id: service.id, + name: service.id, + onClick: () => { + const el = document.getElementById(service.id); + if (el) { + el.scrollIntoView(); + } + }, + })), + }; + } + }, + sections: [ + { + id: InfraMetric.apmMetrics, + label: i18n.translate( + 'xpack.infra.metricDetailPage.apmMetricsLayout.apmTransactionsSection.sectionLabel', + { + defaultMessage: 'APM Transactions', + } + ), + requires: ['apm.transaction'], + type: InfraMetricLayoutSectionType.apm, + visConfig: { seriesOverrides: {} }, + }, + ], + }, +]; diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/container.ts b/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/container.ts index 5c6d13f80643a..cbe6fe7685fd9 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/container.ts +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/container.ts @@ -9,16 +9,18 @@ import { InfraMetric } from '../../../graphql/types'; import { InfraFormatterType } from '../../../lib/lib'; import { nginxLayoutCreator } from './nginx'; import { - InfraMetricLayoutCreator, InfraMetricLayoutSectionType, InfraMetricLayoutVisualizationType, + InfraMetricLayout, } from './types'; +import { apmLayoutCreator } from './apm'; +import { EuiTheme } from '../../../../../../common/eui_styled_components'; -export const containerLayoutCreator: InfraMetricLayoutCreator = theme => [ +export const containerLayoutCreator = (theme: EuiTheme): InfraMetricLayout[] => [ { id: 'containerOverview', label: i18n.translate('xpack.infra.metricDetailPage.containerMetricsLayout.layoutLabel', { - defaultMessage: 'Container', + defaultMessage: 'Container Overview', }), sections: [ { @@ -229,4 +231,5 @@ export const containerLayoutCreator: InfraMetricLayoutCreator = theme => [ ], }, ...nginxLayoutCreator(theme), + ...apmLayoutCreator(), ]; diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/host.ts b/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/host.ts index 590209d4be02c..d3dead1be4edd 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/host.ts +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/host.ts @@ -8,18 +8,20 @@ import { i18n } from '@kbn/i18n'; import { InfraMetric } from '../../../graphql/types'; import { InfraFormatterType } from '../../../lib/lib'; import { nginxLayoutCreator } from './nginx'; +import { apmLayoutCreator } from './apm'; import { awsLayoutCreator } from './aws'; import { - InfraMetricLayoutCreator, InfraMetricLayoutSectionType, InfraMetricLayoutVisualizationType, + InfraMetricLayout, } from './types'; +import { EuiTheme } from '../../../../../../common/eui_styled_components'; -export const hostLayoutCreator: InfraMetricLayoutCreator = theme => [ +export const hostLayoutCreator = (theme: EuiTheme): InfraMetricLayout[] => [ { id: 'hostOverview', label: i18n.translate('xpack.infra.metricDetailPage.hostMetricsLayout.layoutLabel', { - defaultMessage: 'Host', + defaultMessage: 'Host Overview', }), sections: [ { @@ -365,5 +367,6 @@ export const hostLayoutCreator: InfraMetricLayoutCreator = theme => [ ], }, ...nginxLayoutCreator(theme), + ...apmLayoutCreator(), ...awsLayoutCreator(theme), ]; diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/nginx.ts b/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/nginx.ts index efe2408d0ddc7..6f0e8f4dca4c8 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/nginx.ts +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/nginx.ts @@ -8,12 +8,13 @@ import { i18n } from '@kbn/i18n'; import { InfraMetric } from '../../../graphql/types'; import { InfraFormatterType } from '../../../lib/lib'; import { - InfraMetricLayoutCreator, InfraMetricLayoutSectionType, InfraMetricLayoutVisualizationType, + InfraMetricLayout, } from './types'; +import { EuiTheme } from '../../../../../../common/eui_styled_components'; -export const nginxLayoutCreator: InfraMetricLayoutCreator = theme => [ +export const nginxLayoutCreator = (theme: EuiTheme): InfraMetricLayout[] => [ { id: 'nginxOverview', label: 'Nginx', diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/pod.ts b/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/pod.ts index fab30141e5090..d662c4b9d42db 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/pod.ts +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/pod.ts @@ -9,16 +9,18 @@ import { InfraMetric } from '../../../graphql/types'; import { InfraFormatterType } from '../../../lib/lib'; import { nginxLayoutCreator } from './nginx'; import { - InfraMetricLayoutCreator, InfraMetricLayoutSectionType, InfraMetricLayoutVisualizationType, + InfraMetricLayout, } from './types'; +import { apmLayoutCreator } from './apm'; +import { EuiTheme } from '../../../../../../common/eui_styled_components'; -export const podLayoutCreator: InfraMetricLayoutCreator = theme => [ +export const podLayoutCreator = (theme: EuiTheme): InfraMetricLayout[] => [ { id: 'podOverview', label: i18n.translate('xpack.infra.metricDetailPage.podMetricsLayout.layoutLabel', { - defaultMessage: 'Pod', + defaultMessage: 'Pod Overview', }), sections: [ { @@ -156,4 +158,5 @@ export const podLayoutCreator: InfraMetricLayoutCreator = theme => [ ], }, ...nginxLayoutCreator(theme), + ...apmLayoutCreator(), ]; diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/types.ts b/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/types.ts index 74e701981b999..e34c7c2a14132 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/types.ts +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/layouts/types.ts @@ -7,6 +7,7 @@ import { EuiTheme } from '../../../../../../common/eui_styled_components'; import { InfraMetric } from '../../../graphql/types'; import { InfraFormatterType } from '../../../lib/lib'; +import { InfraMetricCombinedData } from '../../../containers/metrics/with_metrics'; export enum InfraMetricLayoutVisualizationType { line = 'line', @@ -17,6 +18,7 @@ export enum InfraMetricLayoutVisualizationType { export enum InfraMetricLayoutSectionType { chart = 'chart', gauges = 'gauges', + apm = 'apm', } interface SeriesOverrides { @@ -49,9 +51,19 @@ export interface InfraMetricLayoutSection { type: InfraMetricLayoutSectionType; } +export interface InfraMetricSideNav { + name: string; + id: string; + items: Array<{ id: string; name: string; onClick: () => void }>; +} + export interface InfraMetricLayout { id: string; label: string; + mapNavItem?: ( + item: InfraMetricLayout, + metrics: InfraMetricCombinedData[] + ) => InfraMetricSideNav | undefined; sections: InfraMetricLayoutSection[]; } diff --git a/x-pack/legacy/plugins/infra/public/utils/is_infra_apm_metrics.ts b/x-pack/legacy/plugins/infra/public/utils/is_infra_apm_metrics.ts new file mode 100644 index 0000000000000..e265a61239fa3 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/utils/is_infra_apm_metrics.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { InfraMetricData } from '../graphql/types'; + +export const isInfraMetricData = (subject: any): subject is InfraMetricData => { + return subject.series && Array.isArray(subject.series); +}; diff --git a/x-pack/legacy/plugins/infra/public/utils/is_infra_metric_data.ts b/x-pack/legacy/plugins/infra/public/utils/is_infra_metric_data.ts new file mode 100644 index 0000000000000..a088d12b1a116 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/utils/is_infra_metric_data.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isRight } from 'fp-ts/lib/Either'; +import { InfraApmMetrics, InfraApmMetricsRT } from '../../common/http_api'; + +export const isInfraApmMetrics = (subject: any): subject is InfraApmMetrics => { + return isRight(InfraApmMetricsRT.decode(subject)) != null; +}; diff --git a/x-pack/legacy/plugins/infra/server/graphql/metrics/schema.gql.ts b/x-pack/legacy/plugins/infra/server/graphql/metrics/schema.gql.ts index 8f1ad0d231eb5..ccbfeea3eb335 100644 --- a/x-pack/legacy/plugins/infra/server/graphql/metrics/schema.gql.ts +++ b/x-pack/legacy/plugins/infra/server/graphql/metrics/schema.gql.ts @@ -35,6 +35,7 @@ export const metricsSchema: any = gql` nginxRequestRate nginxActiveConnections nginxRequestsPerConnection + apmMetrics awsOverview awsCpuUtilization awsNetworkBytes diff --git a/x-pack/legacy/plugins/infra/server/graphql/types.ts b/x-pack/legacy/plugins/infra/server/graphql/types.ts index dee4d569ec733..b4ec773c4bfb8 100644 --- a/x-pack/legacy/plugins/infra/server/graphql/types.ts +++ b/x-pack/legacy/plugins/infra/server/graphql/types.ts @@ -618,6 +618,7 @@ export enum InfraMetric { nginxRequestRate = 'nginxRequestRate', nginxActiveConnections = 'nginxActiveConnections', nginxRequestsPerConnection = 'nginxRequestsPerConnection', + apmMetrics = 'apmMetrics', awsOverview = 'awsOverview', awsCpuUtilization = 'awsCpuUtilization', awsNetworkBytes = 'awsNetworkBytes', diff --git a/x-pack/legacy/plugins/infra/server/infra_server.ts b/x-pack/legacy/plugins/infra/server/infra_server.ts index 98536f4c85d36..236b59b8b80d6 100644 --- a/x-pack/legacy/plugins/infra/server/infra_server.ts +++ b/x-pack/legacy/plugins/infra/server/infra_server.ts @@ -16,6 +16,7 @@ import { InfraBackendLibs } from './lib/infra_types'; import { initLogAnalysisGetLogEntryRateRoute } from './routes/log_analysis'; import { initMetricExplorerRoute } from './routes/metrics_explorer'; import { initMetadataRoute } from './routes/metadata'; +import { initApmMetricsRoute } from './routes/apm_metrics'; export const initInfraServer = (libs: InfraBackendLibs) => { const schema = makeExecutableSchema({ @@ -35,4 +36,5 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initLogAnalysisGetLogEntryRateRoute(libs); initMetricExplorerRoute(libs); initMetadataRoute(libs); + initApmMetricsRoute(libs); }; diff --git a/x-pack/legacy/plugins/infra/server/routes/apm_metrics/index.ts b/x-pack/legacy/plugins/infra/server/routes/apm_metrics/index.ts new file mode 100644 index 0000000000000..731b9cb992578 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/routes/apm_metrics/index.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { throwErrors } from '../../../common/runtime_types'; +import { InfraBackendLibs } from '../../lib/infra_types'; +import { + InfraApmMetricsRequestRT, + InfraApmMetricsRequestWrapped, + InfraApmMetrics, + InfraApmMetricsRT, +} from '../../../common/http_api'; +import { getApmServices } from './lib/get_apm_services'; +import { getApmServiceData } from './lib/get_apm_service_data'; + +export const initApmMetricsRoute = (libs: InfraBackendLibs) => { + const { framework, sources } = libs; + + framework.registerRoute>({ + method: 'POST', + path: '/api/infra/apm_metrics', + handler: async req => { + try { + const { timeRange, nodeId, nodeType, sourceId } = pipe( + InfraApmMetricsRequestRT.decode(req.payload), + fold(throwErrors(Boom.badRequest), identity) + ); + + const { configuration } = await sources.getSourceConfiguration(req, sourceId); + const services = await getApmServices(req, configuration, nodeId, nodeType, timeRange); + const servicesWithData = await Promise.all( + services.map(service => + getApmServiceData(req, configuration, service, nodeId, nodeType, timeRange) + ) + ); + return pipe( + InfraApmMetricsRT.decode({ + id: 'apmMetrics', + services: servicesWithData, + }), + fold(throwErrors(Boom.badImplementation), identity) + ); + } catch (error) { + if (error instanceof Error) { + throw Boom.boomify(error); + } + throw Boom.badImplementation('Recieved a non Error object.'); + } + }, + }); +}; diff --git a/x-pack/legacy/plugins/infra/server/routes/apm_metrics/lib/get_apm_service_data.ts b/x-pack/legacy/plugins/infra/server/routes/apm_metrics/lib/get_apm_service_data.ts new file mode 100644 index 0000000000000..fb91bd6a63e49 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/routes/apm_metrics/lib/get_apm_service_data.ts @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +import { Legacy } from 'kibana'; +import Boom from 'boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { get } from 'lodash'; +import { throwErrors } from '../../../../common/runtime_types'; +import { InfraNodeType } from '../../../../common/http_api/common'; +import { + InfraApmMetricsService, + APMChartResponseRT, + APMDataPoint, + APMTpmBuckets, + InfraApmMetricsTransactionType, + APMChartResponse, + InfraApmMetricsDataSet, +} from '../../../../common/http_api'; +import { + InfraFrameworkRequest, + internalInfraFrameworkRequest, +} from '../../../lib/adapters/framework'; +import { InfraSourceConfiguration } from '../../../lib/sources'; +import { getApmFieldName } from '../../../../common/utils/get_apm_field_name'; + +export const getApmServiceData = async ( + req: InfraFrameworkRequest, + sourceConfiguration: InfraSourceConfiguration, + service: InfraApmMetricsService, + nodeId: string, + nodeType: InfraNodeType, + timeRange: { min: number; max: number } +): Promise => { + const getTransactionDataFor = async (type: InfraApmMetricsTransactionType) => + getDataForTransactionType(req, sourceConfiguration, service, type, nodeId, nodeType, timeRange); + const requestSeries = await getTransactionDataFor('request'); + const jobSeries = await getTransactionDataFor('job'); + return { ...service, dataSets: [...requestSeries, ...jobSeries] }; +}; + +const getDataForTransactionType = async ( + req: InfraFrameworkRequest, + sourceConfiguration: InfraSourceConfiguration, + service: InfraApmMetricsService, + transactionType: InfraApmMetricsTransactionType, + nodeId: string, + nodeType: InfraNodeType, + timeRange: { min: number; max: number } +): Promise => { + const nodeField = getApmFieldName(sourceConfiguration, nodeType); + const query = { + start: moment(timeRange.min).toISOString(), + end: moment(timeRange.max).toISOString(), + transactionType, + uiFilters: JSON.stringify({ kuery: `${nodeField}: "${nodeId}"` }), + }; + const params = new URLSearchParams(query); + const internalRequest = req[internalInfraFrameworkRequest]; + + const getTransactionGroupsCharts = get( + internalRequest, + 'server.plugins.apm.getTransactionGroupsCharts' + ) as ( + req: Legacy.Request, + params: { + query: { + start: string; + end: string; + uiFilters: string; + transactionType?: string; + transactionName?: string; + }; + path: { serviceName: string }; + } + ) => any; + if (!getTransactionGroupsCharts) { + throw new Error('APM is not available'); + } + const newRequest = Object.assign( + Object.create(Object.getPrototypeOf(internalRequest)), + internalRequest, + { + url: `/api/apm/services/${service.id}/transaction_groups/charts?${params.toString()}`, + method: 'GET', + query, + path: { + serviceName: service.id, + }, + } + ); + const response = await getTransactionGroupsCharts(newRequest, { + query, + path: { serviceName: service.id }, + }); + + const result = pipe( + APMChartResponseRT.decode(response), + fold( + throwErrors(message => Boom.badImplementation(`Request to APM Failed: ${message}`)), + identity + ) + ); + if (!hasTransactionData(result)) { + return [] as InfraApmMetricsDataSet[]; + } + const { responseTimes, tpmBuckets } = result.apmTimeseries; + return [ + { + id: 'transactionsPerMinute', + type: transactionType, + series: tpmBuckets.map(mapApmBucketToDataBucket), + }, + { + id: 'responseTimes', + type: transactionType, + series: [ + createApmBucket('avg', responseTimes.avg), + createApmBucket('p95', responseTimes.p95), + createApmBucket('p99', responseTimes.p99), + ], + }, + ]; +}; + +const hasTransactionData = (result: APMChartResponse): boolean => { + const { responseTimes, tpmBuckets } = result.apmTimeseries; + const hasTPMData = tpmBuckets.length !== 0; + const hasResponseTimes = + responseTimes.avg.some(r => r.y) || + responseTimes.p95.some(r => r.y) || + responseTimes.p99.some(r => r.y); + return hasTPMData || hasResponseTimes; +}; + +const createApmBucket = (id: string, data: APMDataPoint[]) => { + return { + id, + data: data.map(p => ({ timestamp: p.x, value: p.y })), + }; +}; + +const mapApmBucketToDataBucket = (bucket: APMTpmBuckets) => + createApmBucket(bucket.key, bucket.dataPoints); diff --git a/x-pack/legacy/plugins/infra/server/routes/apm_metrics/lib/get_apm_services.ts b/x-pack/legacy/plugins/infra/server/routes/apm_metrics/lib/get_apm_services.ts new file mode 100644 index 0000000000000..72c6c394f009c --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/routes/apm_metrics/lib/get_apm_services.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +import Boom from 'boom'; +import { Legacy } from 'kibana'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { get } from 'lodash'; +import { throwErrors } from '../../../../common/runtime_types'; +import { APMServiceResponseRT, InfraApmMetricsService } from '../../../../common/http_api'; +import { InfraNodeType } from '../../../../common/http_api/common'; +import { + InfraFrameworkRequest, + internalInfraFrameworkRequest, +} from '../../../lib/adapters/framework'; +import { InfraSourceConfiguration } from '../../../lib/sources'; +import { getApmFieldName } from '../../../../common/utils/get_apm_field_name'; + +export const getApmServices = async ( + req: InfraFrameworkRequest, + sourceConfiguration: InfraSourceConfiguration, + nodeId: string, + nodeType: InfraNodeType, + timeRange: { min: number; max: number } +): Promise => { + const nodeField = getApmFieldName(sourceConfiguration, nodeType); + const query = { + start: moment(timeRange.min).toISOString(), + end: moment(timeRange.max).toISOString(), + uiFilters: JSON.stringify({ kuery: `${nodeField}: "${nodeId}"` }), + }; + const params = new URLSearchParams(query); + const internalRequest = req[internalInfraFrameworkRequest]; + + const getServices = get(internalRequest, 'server.plugins.apm.getServices') as ( + req: Legacy.Request + ) => any; + if (!getServices) { + throw new Error('APM is not available'); + } + const newRequest = Object.assign( + Object.create(Object.getPrototypeOf(internalRequest)), + internalRequest, + { + url: `/api/apm/services?${params.toString()}`, + method: 'GET', + query, + } + ); + const response = await getServices(newRequest); + const result = pipe( + APMServiceResponseRT.decode(response), + fold( + throwErrors(message => Boom.badImplementation(`Request to APM Failed: ${message}`)), + identity + ) + ); + return result.items.map(item => ({ + id: item.serviceName, + dataSets: [], + avgResponseTime: item.avgResponseTime, + agentName: item.agentName, + errorsPerMinute: item.errorsPerMinute, + transactionsPerMinute: item.transactionsPerMinute, + })); +}; diff --git a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_metric_metadata.ts b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_metric_metadata.ts index 812bc27fffc8a..9a49e1100cbdc 100644 --- a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_metric_metadata.ts +++ b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_metric_metadata.ts @@ -12,8 +12,9 @@ import { InfraMetadataAggregationResponse, } from '../../../lib/adapters/framework'; import { InfraSourceConfiguration } from '../../../lib/sources'; -import { getIdFieldName } from './get_id_field_name'; import { NAME_FIELDS } from '../../../lib/constants'; +import { getIdFieldName } from '../../../../common/utils/get_apm_field_name'; +import { InfraNodeType } from '../../../../common/http_api/common'; export interface InfraMetricsAdapterResponse { id: string; @@ -26,7 +27,7 @@ export const getMetricMetadata = async ( req: InfraFrameworkRequest, sourceConfiguration: InfraSourceConfiguration, nodeId: string, - nodeType: 'host' | 'pod' | 'container' + nodeType: InfraNodeType ): Promise => { const idFieldName = getIdFieldName(sourceConfiguration, nodeType); diff --git a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_node_info.ts b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_node_info.ts index 5af25515a42ed..75c9d335a91e8 100644 --- a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_node_info.ts +++ b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_node_info.ts @@ -10,25 +10,25 @@ import { InfraBackendFrameworkAdapter, } from '../../../lib/adapters/framework'; import { InfraSourceConfiguration } from '../../../lib/sources'; -import { InfraNodeType } from '../../../graphql/types'; import { InfraMetadataInfo } from '../../../../common/http_api/metadata_api'; import { getPodNodeName } from './get_pod_node_name'; import { CLOUD_METRICS_MODULES } from '../../../lib/constants'; -import { getIdFieldName } from './get_id_field_name'; +import { getIdFieldName } from '../../../../common/utils/get_apm_field_name'; +import { InfraNodeType } from '../../../../common/http_api/common'; export const getNodeInfo = async ( framework: InfraBackendFrameworkAdapter, req: InfraFrameworkRequest, sourceConfiguration: InfraSourceConfiguration, nodeId: string, - nodeType: 'host' | 'pod' | 'container' + nodeType: InfraNodeType ): Promise => { // If the nodeType is a Kubernetes pod then we need to get the node info // from a host record instead of a pod. This is due to the fact that any host // can report pod details and we can't rely on the host/cloud information associated // with the kubernetes.pod.uid. We need to first lookup the `kubernetes.node.name` // then use that to lookup the host's node information. - if (nodeType === InfraNodeType.pod) { + if (nodeType === 'pod') { const kubernetesNodeName = await getPodNodeName( framework, req, @@ -37,13 +37,7 @@ export const getNodeInfo = async ( nodeType ); if (kubernetesNodeName) { - return getNodeInfo( - framework, - req, - sourceConfiguration, - kubernetesNodeName, - InfraNodeType.host - ); + return getNodeInfo(framework, req, sourceConfiguration, kubernetesNodeName, 'host'); } return {}; } diff --git a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_pod_node_name.ts b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_pod_node_name.ts index 893707a4660ee..56ff63e4178ec 100644 --- a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_pod_node_name.ts +++ b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_pod_node_name.ts @@ -10,14 +10,15 @@ import { InfraBackendFrameworkAdapter, } from '../../../lib/adapters/framework'; import { InfraSourceConfiguration } from '../../../lib/sources'; -import { getIdFieldName } from './get_id_field_name'; +import { getIdFieldName } from '../../../../common/utils/get_apm_field_name'; +import { InfraNodeType } from '../../../../common/http_api/common'; export const getPodNodeName = async ( framework: InfraBackendFrameworkAdapter, req: InfraFrameworkRequest, sourceConfiguration: InfraSourceConfiguration, nodeId: string, - nodeType: 'host' | 'pod' | 'container' + nodeType: InfraNodeType ): Promise => { const params = { allowNoIndices: true, diff --git a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/has_apm_data.ts b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/has_apm_data.ts index 3193cf83978b0..ae3a257da9f96 100644 --- a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/has_apm_data.ts +++ b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/has_apm_data.ts @@ -9,21 +9,19 @@ import { InfraBackendFrameworkAdapter, } from '../../../lib/adapters/framework'; import { InfraSourceConfiguration } from '../../../lib/sources'; -import { getIdFieldName } from './get_id_field_name'; +import { getApmFieldName } from '../../../../common/utils/get_apm_field_name'; +import { InfraNodeType } from '../../../../common/http_api/common'; export const hasAPMData = async ( framework: InfraBackendFrameworkAdapter, req: InfraFrameworkRequest, sourceConfiguration: InfraSourceConfiguration, nodeId: string, - nodeType: 'host' | 'pod' | 'container' + nodeType: InfraNodeType ) => { const config = framework.config(req); const apmIndex = config.get('apm_oss.transactionIndices') || 'apm-*'; - // There is a bug in APM ECS data where host.name is not set. - // This will fixed with: https://github.com/elastic/apm-server/issues/2502 - const nodeFieldName = - nodeType === 'host' ? 'host.hostname' : getIdFieldName(sourceConfiguration, nodeType); + const nodeFieldName = getApmFieldName(sourceConfiguration, nodeType); const params = { allowNoIndices: true, ignoreUnavailable: true, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 5af2b55ae3dbd..f06e2a1651405 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5238,7 +5238,6 @@ "xpack.infra.metrics.emptyViewTitle": "表示するデータがありません。", "xpack.infra.metrics.invalidNodeErrorDescription": "構成をよく確認してください", "xpack.infra.metrics.invalidNodeErrorTitle": "{nodeName} がメトリックデータを収集していないようです", - "xpack.infra.metrics.layoutLabelOverviewTitle": "{layoutLabel} 概要", "xpack.infra.metrics.loadingNodeDataText": "データを読み込み中", "xpack.infra.metrics.refetchButtonLabel": "新規データを確認", "xpack.infra.metricsExplorer.actionsLabel.aria": "{grouping} のアクション", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 859df25d25a8b..2b9496e21be4f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5241,7 +5241,6 @@ "xpack.infra.metrics.emptyViewTitle": "没有可显示的数据。", "xpack.infra.metrics.invalidNodeErrorDescription": "反复检查您的配置", "xpack.infra.metrics.invalidNodeErrorTitle": "似乎 {nodeName} 未在收集任何指标数据", - "xpack.infra.metrics.layoutLabelOverviewTitle": "{layoutLabel} 概览", "xpack.infra.metrics.loadingNodeDataText": "正在加载数据", "xpack.infra.metrics.refetchButtonLabel": "检查新数据", "xpack.infra.metricsExplorer.actionsLabel.aria": "适用于 {grouping} 的操作", diff --git a/x-pack/test/api_integration/apis/infra/apm_metrics.ts b/x-pack/test/api_integration/apis/infra/apm_metrics.ts new file mode 100644 index 0000000000000..6ded002e1c797 --- /dev/null +++ b/x-pack/test/api_integration/apis/infra/apm_metrics.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { first } from 'lodash'; +import { + InfraApmMetrics, + InfraApmMetricsRequest, +} from '../../../../legacy/plugins/infra/common/http_api/apm_metrics_api'; +import { DATES } from './constants'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export const apmMetricsTests = ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + const fetchMetrics = async ( + body: InfraApmMetricsRequest + ): Promise => { + const response = await supertest + .post('/api/infra/apm_metrics') + .set('kbn-xsrf', 'xxx') + .send(body) + .expect(200); + return response.body; + }; + + // eslint-disable-next-line + describe.only('/api/infra/apm_metrics', () => { + const archiveName = 'infra/8.0.0/metrics_and_apm'; + before(() => esArchiver.load(archiveName)); + after(() => esArchiver.unload(archiveName)); + + it('should just work', async () => { + const metrics = await fetchMetrics({ + nodeId: '7ff0afee-9ae8-11e9-9a96-42010a84004d', + nodeType: 'pod', + sourceId: 'default', + timeRange: DATES['8.0.0'].metrics_and_apm, + }); + expect(metrics).to.have.property('services'); + expect(metrics!.services).to.have.length(1); + const firstService = first(metrics!.services); + expect(firstService).to.have.property('id', 'opbeans-go'); + expect(firstService).to.have.property('transactionsPerMinute', 5.2862003063285306); + expect(firstService).to.have.property('avgResponseTime', 8879.423076923076); + expect(firstService).to.have.property('errorsPerMinute', 0); + expect(firstService).to.have.property('agentName', 'go'); + expect(firstService).to.have.property('dataSets'); + expect(firstService.dataSets).to.have.length(2); + const dataSet = firstService.dataSets.find( + d => d.id === 'responseTimes' && d.type === 'request' + ); + expect(dataSet).to.be.ok(); + expect(dataSet!.series).to.have.length(3); + const series = dataSet!.series.find(s => s.id === 'avg'); + expect(series).to.be.ok(); + expect(series!.data).to.have.length(296); + const dataPoint = series!.data.find(d => d.timestamp === 1564432831000); + expect(dataPoint).to.eql({ + timestamp: 1564432831000, + value: 10623, + }); + }); + }); +}; + +// eslint-disable-next-line import/no-default-export +export default apmMetricsTests; diff --git a/x-pack/test/api_integration/apis/infra/constants.ts b/x-pack/test/api_integration/apis/infra/constants.ts index eb91cdb888eb2..b0ce1dc0b5744 100644 --- a/x-pack/test/api_integration/apis/infra/constants.ts +++ b/x-pack/test/api_integration/apis/infra/constants.ts @@ -26,5 +26,9 @@ export const DATES = { min: 1564083185000, max: 1564083493080, }, + metrics_and_apm: { + min: new Date('2019-07-29T20:40:01.165Z').getTime(), + max: new Date('2019-07-29T20:44:56.273Z').getTime(), + }, }, }; diff --git a/x-pack/test/api_integration/apis/infra/index.js b/x-pack/test/api_integration/apis/infra/index.js index 8b8acaf1f3784..f35adb6ced5cd 100644 --- a/x-pack/test/api_integration/apis/infra/index.js +++ b/x-pack/test/api_integration/apis/infra/index.js @@ -19,5 +19,6 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./metrics_explorer')); loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./ip_to_hostname')); + loadTestFile(require.resolve('./apm_metrics')); }); } diff --git a/yarn.lock b/yarn.lock index ed4c7a50be843..184f6a0db21c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2991,11 +2991,6 @@ resolved "https://registry.yarnpkg.com/@types/boom/-/boom-7.2.0.tgz#19c36cbb5811a7493f0f2e37f31d42b28df1abc1" integrity sha512-HonbGsHFbskh9zRAzA6tabcw18mCOsSEOL2ibGAuVqk6e7nElcRmWO5L4UfIHpDbWBWw+eZYFdsQ1+MEGgpcVA== -"@types/boom@7.2.1": - version "7.2.1" - resolved "https://registry.yarnpkg.com/@types/boom/-/boom-7.2.1.tgz#a21e21ba08cc49d17b26baef98e1a77ee4d6cdb0" - integrity sha512-kOiap+kSa4DPoookJXQGQyKy1rjZ55tgfKAh9F0m1NUdukkcwVzpSnXPMH42a5L+U++ugdQlh/xFJu/WAdr1aw== - "@types/caseless@*": version "0.12.2" resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8"