diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index acbd0398b59ba..cc1b6688daa46 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -121,6 +121,28 @@ exports[`Error POD_NAME 1`] = `undefined`; exports[`Error PROCESSOR_EVENT 1`] = `"error"`; +exports[`Error PROFILE_ALLOC_OBJECTS 1`] = `undefined`; + +exports[`Error PROFILE_ALLOC_SPACE 1`] = `undefined`; + +exports[`Error PROFILE_CPU_NS 1`] = `undefined`; + +exports[`Error PROFILE_DURATION 1`] = `undefined`; + +exports[`Error PROFILE_ID 1`] = `undefined`; + +exports[`Error PROFILE_INUSE_OBJECTS 1`] = `undefined`; + +exports[`Error PROFILE_INUSE_SPACE 1`] = `undefined`; + +exports[`Error PROFILE_SAMPLES_COUNT 1`] = `undefined`; + +exports[`Error PROFILE_STACK 1`] = `undefined`; + +exports[`Error PROFILE_TOP_ID 1`] = `undefined`; + +exports[`Error PROFILE_WALL_US 1`] = `undefined`; + exports[`Error SERVICE 1`] = ` Object { "language": Object { @@ -330,6 +352,28 @@ exports[`Span POD_NAME 1`] = `undefined`; exports[`Span PROCESSOR_EVENT 1`] = `"span"`; +exports[`Span PROFILE_ALLOC_OBJECTS 1`] = `undefined`; + +exports[`Span PROFILE_ALLOC_SPACE 1`] = `undefined`; + +exports[`Span PROFILE_CPU_NS 1`] = `undefined`; + +exports[`Span PROFILE_DURATION 1`] = `undefined`; + +exports[`Span PROFILE_ID 1`] = `undefined`; + +exports[`Span PROFILE_INUSE_OBJECTS 1`] = `undefined`; + +exports[`Span PROFILE_INUSE_SPACE 1`] = `undefined`; + +exports[`Span PROFILE_SAMPLES_COUNT 1`] = `undefined`; + +exports[`Span PROFILE_STACK 1`] = `undefined`; + +exports[`Span PROFILE_TOP_ID 1`] = `undefined`; + +exports[`Span PROFILE_WALL_US 1`] = `undefined`; + exports[`Span SERVICE 1`] = ` Object { "name": "service name", @@ -545,6 +589,28 @@ exports[`Transaction POD_NAME 1`] = `undefined`; exports[`Transaction PROCESSOR_EVENT 1`] = `"transaction"`; +exports[`Transaction PROFILE_ALLOC_OBJECTS 1`] = `undefined`; + +exports[`Transaction PROFILE_ALLOC_SPACE 1`] = `undefined`; + +exports[`Transaction PROFILE_CPU_NS 1`] = `undefined`; + +exports[`Transaction PROFILE_DURATION 1`] = `undefined`; + +exports[`Transaction PROFILE_ID 1`] = `undefined`; + +exports[`Transaction PROFILE_INUSE_OBJECTS 1`] = `undefined`; + +exports[`Transaction PROFILE_INUSE_SPACE 1`] = `undefined`; + +exports[`Transaction PROFILE_SAMPLES_COUNT 1`] = `undefined`; + +exports[`Transaction PROFILE_STACK 1`] = `undefined`; + +exports[`Transaction PROFILE_TOP_ID 1`] = `undefined`; + +exports[`Transaction PROFILE_WALL_US 1`] = `undefined`; + exports[`Transaction SERVICE 1`] = ` Object { "language": Object { diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 6ecb1b0a7b097..ffd05b281208d 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -132,3 +132,17 @@ export const LCP_FIELD = 'transaction.marks.agent.largestContentfulPaint'; export const TBT_FIELD = 'transaction.experience.tbt'; export const FID_FIELD = 'transaction.experience.fid'; export const CLS_FIELD = 'transaction.experience.cls'; + +export const PROFILE_ID = 'profile.id'; +export const PROFILE_DURATION = 'profile.duration'; +export const PROFILE_TOP_ID = 'profile.top.id'; +export const PROFILE_STACK = 'profile.stack'; + +export const PROFILE_SAMPLES_COUNT = 'profile.samples.count'; +export const PROFILE_CPU_NS = 'profile.cpu.ns'; +export const PROFILE_WALL_US = 'profile.wall.us'; + +export const PROFILE_ALLOC_OBJECTS = 'profile.alloc_objects.count'; +export const PROFILE_ALLOC_SPACE = 'profile.alloc_space.bytes'; +export const PROFILE_INUSE_OBJECTS = 'profile.inuse_objects.count'; +export const PROFILE_INUSE_SPACE = 'profile.inuse_space.bytes'; diff --git a/x-pack/plugins/apm/common/processor_event.ts b/x-pack/plugins/apm/common/processor_event.ts index 9eb9ee60c1998..57705e7ed4ce0 100644 --- a/x-pack/plugins/apm/common/processor_event.ts +++ b/x-pack/plugins/apm/common/processor_event.ts @@ -10,6 +10,7 @@ export enum ProcessorEvent { error = 'error', metric = 'metric', span = 'span', + profile = 'profile', } /** * Processor events that are searchable in the UI via the query bar. diff --git a/x-pack/plugins/apm/common/profiling.ts b/x-pack/plugins/apm/common/profiling.ts new file mode 100644 index 0000000000000..e185ef704aed0 --- /dev/null +++ b/x-pack/plugins/apm/common/profiling.ts @@ -0,0 +1,112 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { + PROFILE_ALLOC_OBJECTS, + PROFILE_ALLOC_SPACE, + PROFILE_CPU_NS, + PROFILE_INUSE_OBJECTS, + PROFILE_INUSE_SPACE, + PROFILE_SAMPLES_COUNT, + PROFILE_WALL_US, +} from './elasticsearch_fieldnames'; + +export enum ProfilingValueType { + wallTime = 'wall_time', + cpuTime = 'cpu_time', + samples = 'samples', + allocObjects = 'alloc_objects', + allocSpace = 'alloc_space', + inuseObjects = 'inuse_objects', + inuseSpace = 'inuse_space', +} + +export enum ProfilingValueTypeUnit { + ns = 'ns', + us = 'us', + count = 'count', + bytes = 'bytes', +} + +export interface ProfileNode { + id: string; + label: string; + fqn: string; + value: number; + children: string[]; +} + +const config = { + [ProfilingValueType.wallTime]: { + unit: ProfilingValueTypeUnit.us, + label: i18n.translate( + 'xpack.apm.serviceProfiling.valueTypeLabel.wallTime', + { + defaultMessage: 'Wall', + } + ), + field: PROFILE_WALL_US, + }, + [ProfilingValueType.cpuTime]: { + unit: ProfilingValueTypeUnit.ns, + label: i18n.translate('xpack.apm.serviceProfiling.valueTypeLabel.cpuTime', { + defaultMessage: 'On-CPU', + }), + field: PROFILE_CPU_NS, + }, + [ProfilingValueType.samples]: { + unit: ProfilingValueTypeUnit.count, + label: i18n.translate('xpack.apm.serviceProfiling.valueTypeLabel.samples', { + defaultMessage: 'Samples', + }), + field: PROFILE_SAMPLES_COUNT, + }, + [ProfilingValueType.allocObjects]: { + unit: ProfilingValueTypeUnit.count, + label: i18n.translate( + 'xpack.apm.serviceProfiling.valueTypeLabel.allocObjects', + { + defaultMessage: 'Alloc. objects', + } + ), + field: PROFILE_ALLOC_OBJECTS, + }, + [ProfilingValueType.allocSpace]: { + unit: ProfilingValueTypeUnit.bytes, + label: i18n.translate( + 'xpack.apm.serviceProfiling.valueTypeLabel.allocSpace', + { + defaultMessage: 'Alloc. space', + } + ), + field: PROFILE_ALLOC_SPACE, + }, + [ProfilingValueType.inuseObjects]: { + unit: ProfilingValueTypeUnit.count, + label: i18n.translate( + 'xpack.apm.serviceProfiling.valueTypeLabel.inuseObjects', + { + defaultMessage: 'In-use objects', + } + ), + field: PROFILE_INUSE_OBJECTS, + }, + [ProfilingValueType.inuseSpace]: { + unit: ProfilingValueTypeUnit.bytes, + label: i18n.translate( + 'xpack.apm.serviceProfiling.valueTypeLabel.inuseSpace', + { + defaultMessage: 'In-use space', + } + ), + field: PROFILE_INUSE_SPACE, + }, +}; + +export const getValueTypeConfig = (type: ProfilingValueType) => { + return config[type]; +}; diff --git a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap index 82fabff610191..5094287a402ea 100644 --- a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap @@ -8,6 +8,7 @@ exports[`Home component should render services 1`] = ` "setHeaderActionMenu": [Function], }, "config": Object { + "profilingEnabled": false, "serviceMapEnabled": true, "ui": Object { "enabled": false, @@ -95,6 +96,7 @@ exports[`Home component should render traces 1`] = ` "setHeaderActionMenu": [Function], }, "config": Object { + "profilingEnabled": false, "serviceMapEnabled": true, "ui": Object { "enabled": false, diff --git a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx index 08d95aca24714..a7cbd7a79b4a7 100644 --- a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx @@ -114,6 +114,12 @@ function ServiceDetailsTransactions( return ; } +function ServiceDetailsProfiling( + props: RouteComponentProps<{ serviceName: string }> +) { + return ; +} + function SettingsAgentConfiguration(props: RouteComponentProps<{}>) { return ( @@ -307,6 +313,14 @@ export const routes: APMRouteDefinition[] = [ return query.transactionName as string; }, }, + { + exact: true, + path: '/services/:serviceName/profiling', + component: withApmServiceContext(ServiceDetailsProfiling), + breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceProfilingTitle', { + defaultMessage: 'Profiling', + }), + }, { exact: true, path: '/services/:serviceName/service-map', diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx index d2d5c9f6f3a9a..5c9d79f37cc57 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx +++ b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx @@ -8,6 +8,9 @@ import { EuiTab } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { ReactNode } from 'react'; +import { EuiBetaBadge } from '@elastic/eui'; +import { EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup } from '@elastic/eui'; import { isJavaAgentName, isRumAgentName } from '../../../../common/agent_name'; import { enableServiceOverview } from '../../../../common/ui_settings_keys'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; @@ -19,6 +22,7 @@ import { useServiceMapHref } from '../../shared/Links/apm/ServiceMapLink'; import { useServiceNodeOverviewHref } from '../../shared/Links/apm/ServiceNodeOverviewLink'; import { useServiceOverviewHref } from '../../shared/Links/apm/service_overview_link'; import { useTransactionsOverviewHref } from '../../shared/Links/apm/transaction_overview_link'; +import { useServiceProfilingHref } from '../../shared/Links/apm/service_profiling_link'; import { MainTabs } from '../../shared/main_tabs'; import { ErrorGroupOverview } from '../error_group_overview'; import { ServiceMap } from '../ServiceMap'; @@ -26,12 +30,13 @@ import { ServiceNodeOverview } from '../service_node_overview'; import { ServiceMetrics } from '../service_metrics'; import { ServiceOverview } from '../service_overview'; import { TransactionOverview } from '../transaction_overview'; +import { ServiceProfiling } from '../service_profiling'; import { Correlations } from '../correlations'; interface Tab { key: string; href: string; - text: string; + text: ReactNode; render: () => ReactNode; } @@ -43,12 +48,16 @@ interface Props { | 'nodes' | 'overview' | 'service-map' + | 'profiling' | 'transactions'; } export function ServiceDetailTabs({ serviceName, tab }: Props) { const { agentName } = useApmServiceContext(); - const { uiSettings } = useApmPluginContext().core; + const { + core: { uiSettings }, + config, + } = useApmPluginContext(); const { urlParams: { latencyAggregationType }, } = useUrlParams(); @@ -114,6 +123,38 @@ export function ServiceDetailTabs({ serviceName, tab }: Props) { ) : null, }; + const profilingTab = { + key: 'profiling', + href: useServiceProfilingHref({ serviceName }), + text: ( + + + {i18n.translate('xpack.apm.serviceDetails.profilingTabLabel', { + defaultMessage: 'Profiling', + })} + + + + + + ), + render: () => , + }; + const tabs: Tab[] = [transactionsTab, errorsTab]; if (uiSettings.get(enableServiceOverview)) { @@ -128,6 +169,10 @@ export function ServiceDetailTabs({ serviceName, tab }: Props) { tabs.push(serviceMapTab); + if (config.profilingEnabled) { + tabs.push(profilingTab); + } + const selectedTab = tabs.find((serviceTab) => serviceTab.key === tab); return ( diff --git a/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx b/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx new file mode 100644 index 0000000000000..09a42f9b2df90 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx @@ -0,0 +1,138 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiPage, + EuiPanel, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useState } from 'react'; +import { + getValueTypeConfig, + ProfilingValueType, +} from '../../../../common/profiling'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useFetcher } from '../../../hooks/use_fetcher'; +import { SearchBar } from '../../shared/search_bar'; +import { ServiceProfilingFlamegraph } from './service_profiling_flamegraph'; +import { ServiceProfilingTimeline } from './service_profiling_timeline'; + +interface ServiceProfilingProps { + serviceName: string; + environment?: string; +} + +export function ServiceProfiling({ + serviceName, + environment, +}: ServiceProfilingProps) { + const { + urlParams: { start, end }, + uiFilters, + } = useUrlParams(); + + const { data = [] } = useFetcher( + (callApmApi) => { + if (!start || !end) { + return; + } + + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/profiling/timeline', + params: { + path: { serviceName }, + query: { + start, + end, + environment, + uiFilters: JSON.stringify(uiFilters), + }, + }, + }); + }, + [start, end, serviceName, environment, uiFilters] + ); + + const [valueType, setValueType] = useState(); + + useEffect(() => { + if (!data.length) { + return; + } + + const availableValueTypes = data.reduce((set, point) => { + (Object.keys(point.valueTypes).filter( + (type) => type !== 'unknown' + ) as ProfilingValueType[]) + .filter((type) => point.valueTypes[type] > 0) + .forEach((type) => { + set.add(type); + }); + + return set; + }, new Set()); + + if (!valueType || !availableValueTypes.has(valueType)) { + setValueType(Array.from(availableValueTypes)[0]); + } + }, [data, valueType]); + + return ( + <> + + + + + +

+ {i18n.translate('xpack.apm.profilingOverviewTitle', { + defaultMessage: 'Profiling', + })} +

+
+
+ + + + + { + setValueType(type); + }} + selectedValueType={valueType} + /> + + {valueType ? ( + + +

{getValueTypeConfig(valueType).label}

+
+
+ ) : null} + + + +
+
+
+
+
+ + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_flamegraph.tsx b/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_flamegraph.tsx new file mode 100644 index 0000000000000..03248d2836674 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_flamegraph.tsx @@ -0,0 +1,419 @@ +/* + * 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 { + Chart, + Datum, + Partition, + PartitionLayout, + PrimitiveValue, + Settings, + TooltipInfo, +} from '@elastic/charts'; +import { EuiInMemoryTable } from '@elastic/eui'; +import { EuiFieldText } from '@elastic/eui'; +import { EuiToolTip } from '@elastic/eui'; +import { + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + euiPaletteForTemperature, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { find, sumBy } from 'lodash'; +import { rgba } from 'polished'; +import React, { useMemo, useState } from 'react'; +import seedrandom from 'seedrandom'; +import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; +import { useChartTheme } from '../../../../../observability/public'; +import { + getValueTypeConfig, + ProfileNode, + ProfilingValueType, + ProfilingValueTypeUnit, +} from '../../../../common/profiling'; +import { + asDuration, + asDynamicBytes, + asInteger, +} from '../../../../common/utils/formatters'; +import { UIFilters } from '../../../../typings/ui_filters'; +import { useFetcher } from '../../../hooks/use_fetcher'; +import { useTheme } from '../../../hooks/use_theme'; +import { px, unit } from '../../../style/variables'; + +const colors = euiPaletteForTemperature(100).slice(50, 85); + +interface ProfileDataPoint { + id: string; + value: number; + depth: number; + layers: Record; +} + +const TooltipContainer = euiStyled.div` + background-color: ${(props) => props.theme.eui.euiColorDarkestShade}; + border-radius: ${(props) => props.theme.eui.euiBorderRadius}; + color: ${(props) => props.theme.eui.euiColorLightestShade}; + padding: ${(props) => props.theme.eui.paddingSizes.s}; +`; + +const formatValue = ( + value: number, + valueUnit: ProfilingValueTypeUnit +): string => { + switch (valueUnit) { + case ProfilingValueTypeUnit.ns: + return asDuration(value / 1000); + + case ProfilingValueTypeUnit.us: + return asDuration(value); + + case ProfilingValueTypeUnit.count: + return asInteger(value); + + case ProfilingValueTypeUnit.bytes: + return asDynamicBytes(value); + } +}; + +function CustomTooltip({ + values, + nodes, + valueUnit, +}: TooltipInfo & { + nodes: Record; + valueUnit: ProfilingValueTypeUnit; +}) { + const first = values[0]; + + const foundNode = find(nodes, (node) => node.label === first.label); + + const label = foundNode?.fqn ?? first.label; + const value = formatValue(first.value, valueUnit) + first.formattedValue; + + return ( + + + + + + + {label} + + + + {value} + + + + + ); +} + +export function ServiceProfilingFlamegraph({ + serviceName, + environment, + valueType, + start, + end, + uiFilters, +}: { + serviceName: string; + environment?: string; + valueType?: ProfilingValueType; + start?: string; + end?: string; + uiFilters: UIFilters; +}) { + const theme = useTheme(); + + const [collapseSimilarFrames, setCollapseSimilarFrames] = useState(true); + const [highlightFilter, setHighlightFilter] = useState(''); + + const { data } = useFetcher( + (callApmApi) => { + if (!start || !end || !valueType) { + return undefined; + } + + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/profiling/statistics', + params: { + path: { + serviceName, + }, + query: { + start, + end, + environment, + valueType, + uiFilters: JSON.stringify(uiFilters), + }, + }, + }); + }, + [start, end, environment, serviceName, valueType, uiFilters] + ); + + const points = useMemo(() => { + if (!data) { + return []; + } + + const { rootNodes, nodes } = data; + + const getDataPoints = ( + node: ProfileNode, + depth: number + ): ProfileDataPoint[] => { + const { children } = node; + + if (!children.length) { + // edge + return [ + { + id: node.id, + value: node.value, + depth, + layers: { + [depth]: node.id, + }, + }, + ]; + } + + const directChildNodes = children.map((childId) => nodes[childId]); + + const shouldCollapse = + collapseSimilarFrames && + node.value === 0 && + directChildNodes.length === 1 && + directChildNodes[0].value === 0; + + const nextDepth = shouldCollapse ? depth : depth + 1; + + const childDataPoints = children.flatMap((childId) => + getDataPoints(nodes[childId], nextDepth) + ); + + if (!shouldCollapse) { + childDataPoints.forEach((point) => { + point.layers[depth] = node.id; + }); + } + + const totalTime = sumBy(childDataPoints, 'value'); + const selfTime = node.value - totalTime; + + if (selfTime === 0) { + return childDataPoints; + } + + return [ + ...childDataPoints, + { + id: '', + value: selfTime, + layers: { [nextDepth]: '' }, + depth, + }, + ]; + }; + + const root = { + id: 'root', + label: 'root', + fqn: 'root', + children: rootNodes, + value: 0, + }; + + nodes.root = root; + + return getDataPoints(root, 0); + }, [data, collapseSimilarFrames]); + + const layers = useMemo(() => { + if (!data || !points.length) { + return []; + } + + const { nodes } = data; + + const maxDepth = Math.max(...points.map((point) => point.depth)); + + return [...new Array(maxDepth)].map((_, depth) => { + return { + groupByRollup: (d: Datum) => d.layers[depth], + nodeLabel: (id: PrimitiveValue) => { + if (nodes[id!]) { + return nodes[id!].label; + } + return ''; + }, + showAccessor: (id: PrimitiveValue) => !!id, + shape: { + fillColor: (d: { dataName: string }) => { + const node = nodes[d.dataName]; + + if ( + !node || + // TODO: apply highlight to entire stack, not just node + (highlightFilter && !node.fqn.includes(highlightFilter)) + ) { + return rgba(0, 0, 0, 0.25); + } + + const integer = + Math.abs(seedrandom(d.dataName).int32()) % colors.length; + return colors[integer]; + }, + }, + }; + }); + }, [points, highlightFilter, data]); + + const chartTheme = useChartTheme(); + + const chartSize = { + height: layers.length * 20, + width: '100%', + }; + + const items = Object.values(data?.nodes ?? {}).filter((node) => + highlightFilter ? node.fqn.includes(highlightFilter) : true + ); + + const valueUnit = valueType + ? getValueTypeConfig(valueType).unit + : ProfilingValueTypeUnit.count; + + return ( + + + + ( + + ), + }} + /> + d.value as number} + valueFormatter={() => ''} + config={{ + fillLabel: { + fontFamily: theme.eui.euiCodeFontFamily, + // @ts-expect-error (coming soon in Elastic charts) + clipText: true, + }, + drilldown: true, + fontFamily: theme.eui.euiCodeFontFamily, + minFontSize: 9, + maxFontSize: 9, + maxRowCount: 1, + partitionLayout: PartitionLayout.icicle, + }} + /> + + + + + + { + setCollapseSimilarFrames((state) => !state); + }} + label={i18n.translate( + 'xpack.apm.profiling.collapseSimilarFrames', + { + defaultMessage: 'Collapse similar', + } + )} + /> + + + { + if (!e.target.value) { + setHighlightFilter(''); + } + }} + onKeyPress={(e) => { + if (e.charCode === 13) { + setHighlightFilter(() => (e.target as any).value); + } + }} + /> + + + { + return ( + + {item.label} + + ); + }, + }, + { + field: 'value', + name: i18n.translate('xpack.apm.profiling.table.value', { + defaultMessage: 'Self', + }), + render: (_, item) => formatValue(item.value, valueUnit), + width: px(unit * 6), + }, + ]} + /> + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_timeline.tsx b/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_timeline.tsx new file mode 100644 index 0000000000000..d5dc2f5d56afc --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_timeline.tsx @@ -0,0 +1,147 @@ +/* + * 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 { + Axis, + BarSeries, + Chart, + niceTimeFormatter, + Position, + ScaleType, + Settings, +} from '@elastic/charts'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiText, + euiPaletteColorBlind, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useChartTheme } from '../../../../../observability/public'; +import { + getValueTypeConfig, + ProfilingValueType, +} from '../../../../common/profiling'; + +type ProfilingTimelineItem = { + x: number; +} & { valueTypes: Record }; + +const palette = euiPaletteColorBlind(); + +export function ServiceProfilingTimeline({ + start, + end, + series, + onValueTypeSelect, + selectedValueType, +}: { + series: ProfilingTimelineItem[]; + start: string; + end: string; + onValueTypeSelect: (valueType: ProfilingValueType) => void; + selectedValueType: ProfilingValueType | undefined; +}) { + const chartTheme = useChartTheme(); + + const xFormat = niceTimeFormatter([Date.parse(start), Date.parse(end)]); + + function getSeriesForValueType(type: ProfilingValueType | 'unknown') { + const label = + type === 'unknown' + ? i18n.translate('xpack.apm.serviceProfiling.valueTypeLabel.unknown', { + defaultMessage: 'Other', + }) + : getValueTypeConfig(type).label; + + return { + name: label, + id: type, + data: series.map((coord) => ({ + x: coord.x, + y: coord.valueTypes[type], + })), + }; + } + + const specs = [ + getSeriesForValueType('unknown'), + ...Object.values(ProfilingValueType).map((type) => + getSeriesForValueType(type) + ), + ] + .filter((spec) => spec.data.some((coord) => coord.y > 0)) + .map((spec, index) => { + return { + ...spec, + color: palette[index], + }; + }); + + return ( + + + + + + + {specs.map((spec) => ( + + ))} + + + + + {specs.map((spec) => ( + + + + + + + { + if (spec.id !== 'unknown') { + onValueTypeSelect(spec.id); + } + }} + > + + {spec.name} + + + + + + ))} + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/service_profiling_link.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/service_profiling_link.tsx new file mode 100644 index 0000000000000..ab3b085e4e255 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/service_profiling_link.tsx @@ -0,0 +1,39 @@ +/* + * 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 { EuiLink } from '@elastic/eui'; +import React from 'react'; +import { APMLinkExtendProps, useAPMHref } from './APMLink'; + +interface ServiceProfilingLinkProps extends APMLinkExtendProps { + serviceName: string; + environment?: string; +} + +export function useServiceProfilingHref({ + serviceName, + environment, +}: ServiceProfilingLinkProps) { + const query = environment + ? { + environment, + } + : {}; + return useAPMHref({ + path: `/services/${serviceName}/profiling`, + query, + }); +} + +export function ServiceProfilingLink({ + serviceName, + environment, + ...rest +}: ServiceProfilingLinkProps) { + const href = useServiceProfilingHref({ serviceName, environment }); + return ; +} diff --git a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx index d17a4a27b646c..024deca558497 100644 --- a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx @@ -81,6 +81,7 @@ const mockConfig: ConfigSchema = { ui: { enabled: false, }, + profilingEnabled: false, }; const mockPlugin = { diff --git a/x-pack/plugins/apm/public/index.ts b/x-pack/plugins/apm/public/index.ts index 5460c6dc625a6..2734269b9cff9 100644 --- a/x-pack/plugins/apm/public/index.ts +++ b/x-pack/plugins/apm/public/index.ts @@ -13,6 +13,7 @@ import { ApmPlugin, ApmPluginSetup, ApmPluginStart } from './plugin'; export interface ConfigSchema { serviceMapEnabled: boolean; + profilingEnabled: boolean; ui: { enabled: boolean; }; diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 52b5765a984d5..00910353ac278 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -16,6 +16,7 @@ export const config = { exposeToBrowser: { serviceMapEnabled: true, ui: true, + profilingEnabled: true, }, schema: schema.object({ enabled: schema.boolean({ defaultValue: true }), @@ -47,6 +48,7 @@ export const config = { metricsInterval: schema.number({ defaultValue: 30 }), maxServiceEnvironments: schema.number({ defaultValue: 100 }), maxServiceSelection: schema.number({ defaultValue: 50 }), + profilingEnabled: schema.boolean({ defaultValue: false }), }), }; diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index c47d511ca565c..368c0eb305f21 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -6,6 +6,7 @@ */ import { ValuesType } from 'utility-types'; +import { Profile } from '../../../../../typings/es_schemas/ui/profile'; import { ElasticsearchClient, KibanaRequest, @@ -43,6 +44,7 @@ type TypeOfProcessorEvent = { transaction: Transaction; span: Span; metric: Metric; + profile: Profile; }[T]; type ESSearchRequestOf = Omit< diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.ts index eef9aff946ea7..38989d172a73f 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.ts @@ -23,6 +23,8 @@ const processorEventIndexMap: Record = { [ProcessorEvent.span]: 'apm_oss.spanIndices', [ProcessorEvent.metric]: 'apm_oss.metricsIndices', [ProcessorEvent.error]: 'apm_oss.errorIndices', + // TODO: should have its own config setting + [ProcessorEvent.profile]: 'apm_oss.transactionIndices', }; export function unpackProcessorEvents( diff --git a/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_statistics.ts b/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_statistics.ts new file mode 100644 index 0000000000000..0c9bbb35be631 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_statistics.ts @@ -0,0 +1,279 @@ +/* + * 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 { keyBy, last } from 'lodash'; +import { Logger } from 'kibana/server'; +import util from 'util'; +import { maybe } from '../../../../common/utils/maybe'; +import { ProfileStackFrame } from '../../../../typings/es_schemas/ui/profile'; +import { + ProfilingValueType, + ProfileNode, + getValueTypeConfig, +} from '../../../../common/profiling'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { ESFilter } from '../../../../../../typings/elasticsearch'; +import { + PROFILE_STACK, + PROFILE_TOP_ID, + SERVICE_NAME, +} from '../../../../common/elasticsearch_fieldnames'; +import { rangeQuery, environmentQuery } from '../../../../common/utils/queries'; +import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; + +const MAX_STACK_IDS = 10000; +const MAX_STACKS_PER_REQUEST = 1000; + +const maybeAdd = (to: any[], value: any) => { + if (to.includes(value)) { + return; + } + + to.push(value); +}; + +function getProfilingStats({ + apmEventClient, + filter, + valueTypeField, +}: { + apmEventClient: APMEventClient; + filter: ESFilter[]; + valueTypeField: string; +}) { + return withApmSpan('get_profiling_stats', async () => { + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.profile], + }, + body: { + size: 0, + query: { + bool: { + filter, + }, + }, + aggs: { + stacks: { + terms: { + field: PROFILE_TOP_ID, + size: MAX_STACK_IDS, + order: { + value: 'desc', + }, + }, + aggs: { + value: { + sum: { + field: valueTypeField, + }, + }, + }, + }, + }, + }, + }); + + const stacks = + response.aggregations?.stacks.buckets.map((stack) => { + return { + id: stack.key as string, + value: stack.value.value!, + }; + }) ?? []; + + return stacks; + }); +} + +function getProfilesWithStacks({ + apmEventClient, + filter, +}: { + apmEventClient: APMEventClient; + filter: ESFilter[]; +}) { + return withApmSpan('get_profiles_with_stacks', async () => { + const cardinalityResponse = await withApmSpan('get_top_cardinality', () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.profile], + }, + body: { + size: 0, + query: { + bool: { filter }, + }, + aggs: { + top: { + cardinality: { + field: PROFILE_TOP_ID, + }, + }, + }, + }, + }) + ); + + const cardinality = cardinalityResponse.aggregations?.top.value ?? 0; + + const numStacksToFetch = Math.min( + Math.ceil(cardinality * 1.1), + MAX_STACK_IDS + ); + + const partitions = Math.ceil(numStacksToFetch / MAX_STACKS_PER_REQUEST); + + if (partitions === 0) { + return []; + } + + const allResponses = await withApmSpan('get_all_stacks', async () => { + return Promise.all( + [...new Array(partitions)].map(async (_, num) => { + const response = await withApmSpan('get_partition', () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.profile], + }, + body: { + query: { + bool: { + filter, + }, + }, + aggs: { + top: { + terms: { + field: PROFILE_TOP_ID, + size: Math.max(MAX_STACKS_PER_REQUEST), + include: { + num_partitions: partitions, + partition: num, + }, + }, + aggs: { + latest: { + top_hits: { + _source: [PROFILE_TOP_ID, PROFILE_STACK], + }, + }, + }, + }, + }, + }, + }) + ); + + return ( + response.aggregations?.top.buckets.flatMap((bucket) => { + return bucket.latest.hits.hits[0]._source; + }) ?? [] + ); + }) + ); + }); + + return allResponses.flat(); + }); +} + +export async function getServiceProfilingStatistics({ + serviceName, + setup, + environment, + valueType, + logger, +}: { + serviceName: string; + setup: Setup & SetupTimeRange; + environment?: string; + valueType: ProfilingValueType; + logger: Logger; +}) { + return withApmSpan('get_service_profiling_statistics', async () => { + const { apmEventClient, start, end } = setup; + + const valueTypeField = getValueTypeConfig(valueType).field; + + const filter: ESFilter[] = [ + ...rangeQuery(start, end), + { term: { [SERVICE_NAME]: serviceName } }, + ...environmentQuery(environment), + { exists: { field: valueTypeField } }, + ...setup.esFilter, + ]; + + const [profileStats, profileStacks] = await Promise.all([ + getProfilingStats({ apmEventClient, filter, valueTypeField }), + getProfilesWithStacks({ apmEventClient, filter }), + ]); + + const nodes: Record = {}; + const rootNodes: string[] = []; + + function getNode(frame: ProfileStackFrame) { + const { id, filename, function: functionName, line } = frame; + const location = [functionName, line].filter(Boolean).join(':'); + const fqn = [filename, location].filter(Boolean).join('/'); + const label = last(location.split('/'))!; + let node = nodes[id]; + if (!node) { + node = { id, label, fqn, value: 0, children: [] }; + nodes[id] = node; + } + return node; + } + + const stackStatsById = keyBy(profileStats, 'id'); + + const missingStacks: string[] = []; + + profileStacks.forEach((profile) => { + const stats = maybe(stackStatsById[profile.profile.top.id]); + + if (!stats) { + missingStacks.push(profile.profile.top.id); + return; + } + + const frames = profile.profile.stack.concat().reverse(); + + frames.forEach((frame, index) => { + const node = getNode(frame); + + if (index === frames.length - 1 && stats) { + node.value += stats.value; + } + + if (index === 0) { + // root node + maybeAdd(rootNodes, node.id); + } else { + const parent = nodes[frames[index - 1].id]; + maybeAdd(parent.children, node.id); + } + }); + }); + + if (missingStacks.length > 0) { + logger.warn( + `Could not find stats for all stacks: ${util.inspect({ + numProfileStats: profileStats.length, + numStacks: profileStacks.length, + missing: missingStacks, + })}` + ); + } + + return { + nodes, + rootNodes, + }; + }); +} diff --git a/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_timeline.ts b/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_timeline.ts new file mode 100644 index 0000000000000..dc29d6a43d82d --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_timeline.ts @@ -0,0 +1,121 @@ +/* + * 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 { mapKeys, mapValues } from 'lodash'; +import { rangeQuery, environmentQuery } from '../../../../common/utils/queries'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { + PROFILE_ID, + SERVICE_NAME, +} from '../../../../common/elasticsearch_fieldnames'; +import { + getValueTypeConfig, + ProfilingValueType, +} from '../../../../common/profiling'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { getBucketSize } from '../../helpers/get_bucket_size'; +import { withApmSpan } from '../../../utils/with_apm_span'; + +const configMap = mapValues( + mapKeys(ProfilingValueType, (val, key) => val), + (value) => getValueTypeConfig(value) +) as Record>; + +const allFields = Object.values(configMap).map((config) => config.field); + +export async function getServiceProfilingTimeline({ + serviceName, + environment, + setup, +}: { + serviceName: string; + setup: Setup & SetupTimeRange; + environment?: string; +}) { + return withApmSpan('get_service_profiling_timeline', async () => { + const { apmEventClient, start, end, esFilter } = setup; + + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.profile], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ], + }, + }, + aggs: { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: getBucketSize({ start, end }).intervalString, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: { + value_type: { + filters: { + filters: { + unknown: { + bool: { + must_not: allFields.map((field) => ({ + exists: { field }, + })), + }, + }, + ...mapValues(configMap, ({ field }) => ({ + exists: { field }, + })), + }, + }, + aggs: { + num_profiles: { + cardinality: { + field: PROFILE_ID, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const { aggregations } = response; + + if (!aggregations) { + return []; + } + + return aggregations.timeseries.buckets.map((bucket) => { + return { + x: bucket.key, + valueTypes: { + unknown: bucket.value_type.buckets.unknown.num_profiles.value, + // TODO: use enum as object key. not possible right now + // because of https://github.com/microsoft/TypeScript/issues/37888 + ...mapValues(configMap, (_, key) => { + return ( + bucket.value_type.buckets[key as ProfilingValueType]?.num_profiles + .value ?? 0 + ); + }), + }, + }; + }); + }); +} diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index c96e02f6c1821..2bd7e25e848c8 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -31,6 +31,8 @@ import { serviceMetadataDetailsRoute, serviceMetadataIconsRoute, serviceInstancesRoute, + serviceProfilingStatisticsRoute, + serviceProfilingTimelineRoute, } from './services'; import { agentConfigurationRoute, @@ -134,6 +136,8 @@ const createApmApi = () => { .add(serviceMetadataIconsRoute) .add(serviceInstancesRoute) .add(serviceErrorGroupsComparisonStatisticsRoute) + .add(serviceProfilingTimelineRoute) + .add(serviceProfilingStatisticsRoute) // Agent configuration .add(getSingleAgentConfigurationRoute) diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 2ce41f3d1e1a0..86f7853647894 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -34,6 +34,9 @@ import { uiFiltersRt, } from './default_api_types'; import { withApmSpan } from '../utils/with_apm_span'; +import { getServiceProfilingStatistics } from '../lib/services/profiling/get_service_profiling_statistics'; +import { getServiceProfilingTimeline } from '../lib/services/profiling/get_service_profiling_timeline'; +import { ProfilingValueType } from '../../common/profiling'; import { latencyAggregationTypeRt, LatencyAggregationType, @@ -179,12 +182,7 @@ export const serviceAnnotationsRoute = createRoute({ path: t.type({ serviceName: t.string, }), - query: t.intersection([ - rangeRt, - t.partial({ - environment: t.string, - }), - ]), + query: t.intersection([rangeRt, environmentRt]), }), options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { @@ -488,3 +486,82 @@ export const serviceDependenciesRoute = createRoute({ }); }, }); + +export const serviceProfilingTimelineRoute = createRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/profiling/timeline', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + rangeRt, + uiFiltersRt, + t.partial({ + environment: t.string, + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + + const { + path: { serviceName }, + query: { environment }, + } = context.params; + + return getServiceProfilingTimeline({ + setup, + serviceName, + environment, + }); + }, +}); + +export const serviceProfilingStatisticsRoute = createRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/profiling/statistics', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + rangeRt, + uiFiltersRt, + t.partial({ + environment: t.string, + }), + t.type({ + valueType: t.union([ + t.literal(ProfilingValueType.wallTime), + t.literal(ProfilingValueType.cpuTime), + t.literal(ProfilingValueType.samples), + t.literal(ProfilingValueType.allocObjects), + t.literal(ProfilingValueType.allocSpace), + t.literal(ProfilingValueType.inuseObjects), + t.literal(ProfilingValueType.inuseSpace), + ]), + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + + const { + path: { serviceName }, + query: { environment, valueType }, + } = context.params; + + return getServiceProfilingStatistics({ + serviceName, + environment, + valueType, + setup, + logger: context.logger, + }); + }, +}); diff --git a/x-pack/plugins/apm/typings/es_schemas/ui/profile.ts b/x-pack/plugins/apm/typings/es_schemas/ui/profile.ts new file mode 100644 index 0000000000000..e8fbe8805fd0f --- /dev/null +++ b/x-pack/plugins/apm/typings/es_schemas/ui/profile.ts @@ -0,0 +1,40 @@ +/* + * 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 { Observer } from '@elastic/eui/src/components/observer/observer'; +import { Agent } from './fields/agent'; + +export interface ProfileStackFrame { + filename?: string; + line?: string; + function: string; + id: string; +} + +export interface Profile { + agent: Agent; + '@timestamp': string; + labels?: { + [key: string]: string | number | boolean; + }; + observer?: Observer; + profile: { + top: ProfileStackFrame; + duration: number; + stack: ProfileStackFrame[]; + id: string; + wall?: { + us: number; + }; + cpu?: { + ns: number; + }; + samples: { + count: number; + }; + }; +} diff --git a/x-pack/typings/elasticsearch/index.d.ts b/x-pack/typings/elasticsearch/index.d.ts index b174db739b030..41630e81f13e4 100644 --- a/x-pack/typings/elasticsearch/index.d.ts +++ b/x-pack/typings/elasticsearch/index.d.ts @@ -25,7 +25,7 @@ export type MaybeReadonlyArray = T[] | readonly T[]; interface CollapseQuery { field: string; - inner_hits: { + inner_hits?: { name: string; size?: number; sort?: SortOptions;