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;