[APM] Aggregated critical path (#144092)

This commit is contained in:
Dario Gieselaar 2022-11-02 14:46:05 +01:00 committed by GitHub
parent 14ce15e6e8
commit 65f0c385d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 2058 additions and 301 deletions

View file

@ -0,0 +1,92 @@
/*
* 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 { sumBy } from 'lodash';
import type { CriticalPathResponse } from '../../server/routes/traces/get_aggregated_critical_path';
export interface CriticalPathTreeNode {
nodeId: string;
children: CriticalPathTreeNode[];
countInclusive: number;
countExclusive: number;
}
export function getAggregatedCriticalPathRootNodes(params: {
criticalPath: CriticalPathResponse;
}): {
rootNodes: CriticalPathTreeNode[];
maxDepth: number;
numNodes: number;
} {
let maxDepth = 20; // min max depth
const { criticalPath } = params;
let numNodes = 0;
function mergeNodesWithSameOperationId(
nodes: CriticalPathTreeNode[]
): CriticalPathTreeNode[] {
const nodesByOperationId: Record<string, CriticalPathTreeNode> = {};
const mergedNodes = nodes.reduce<CriticalPathTreeNode[]>(
(prev, node, index, array) => {
const nodeId = node.nodeId;
const operationId = criticalPath.operationIdByNodeId[nodeId];
if (nodesByOperationId[operationId]) {
const prevNode = nodesByOperationId[operationId];
prevNode.children.push(...node.children);
prevNode.countExclusive += node.countExclusive;
prevNode.countInclusive += node.countInclusive;
return prev;
}
nodesByOperationId[operationId] = node;
prev.push(node);
return prev;
},
[]
);
numNodes += mergedNodes.length;
mergedNodes.forEach((node) => {
node.children = mergeNodesWithSameOperationId(node.children);
});
return mergedNodes;
}
function getNode(nodeId: string, depth: number): CriticalPathTreeNode {
maxDepth = Math.max(maxDepth, depth);
const children = criticalPath.nodes[nodeId].map((childNodeId) =>
getNode(childNodeId, depth + 1)
);
const nodeCountExclusive = criticalPath.timeByNodeId[nodeId] || 0;
const nodeCountInclusive =
sumBy(children, (child) => child.countInclusive) + nodeCountExclusive;
return {
nodeId,
children,
countInclusive: nodeCountInclusive,
countExclusive: nodeCountExclusive,
};
}
const rootNodes = mergeNodesWithSameOperationId(
criticalPath.rootNodes.map((nodeId) => getNode(nodeId, 1))
);
return {
rootNodes,
maxDepth,
numNodes,
};
}

View file

@ -37,7 +37,10 @@ export function getCriticalPath(waterfall: IWaterfall): CriticalPath {
const orderedChildren = directChildren.concat().sort((a, b) => {
const endTimeA = a.offset + a.skew + a.duration;
const endTimeB = b.offset + b.skew + b.duration;
return endTimeB - endTimeA;
if (endTimeA === endTimeB) {
return 0;
}
return endTimeB > endTimeA ? 1 : -1;
});
// For each point in time, determine what child is on the critical path.

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
// for API tests
export { getAggregatedCriticalPathRootNodes } from './critical_path/get_aggregated_critical_path_root_nodes';

View file

@ -96,7 +96,7 @@ export function DetailView({ errorGroup, urlParams, kuery }: Props) {
const method = error.http?.request?.method;
const status = error.http?.response?.status_code;
const traceExplorerLink = router.link('/traces/explorer', {
const traceExplorerLink = router.link('/traces/explorer/waterfall', {
query: {
...query,
showCriticalPath: false,

View file

@ -49,7 +49,7 @@ export function EdgeContents({ elementData }: ContentsProps) {
` [ span where service.name == "${sourceService}" and span.destination.service.resource == "${edgeData.targetData[SPAN_DESTINATION_SERVICE_RESOURCE]}" ]`;
}
const url = apmRouter.link('/traces/explorer', {
const url = apmRouter.link('/traces/explorer/waterfall', {
query: {
...query,
type: TraceSearchType.eql,

View file

@ -44,18 +44,20 @@ export function TopTracesOverview() {
);
return (
<>
<SearchBar />
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<SearchBar />
</EuiFlexItem>
{fallbackToTransactions && (
<EuiFlexGroup>
<EuiFlexItem>
<AggregatedTransactionsBadge />
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem grow={false}>
<AggregatedTransactionsBadge />
</EuiFlexItem>
)}
<TraceList response={response} />
</>
<EuiFlexItem grow>
<TraceList response={response} />
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -4,7 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useMemo, useState } from 'react';
import { useHistory } from 'react-router-dom';
import {
@ -12,39 +13,38 @@ import {
TraceSearchType,
} from '../../../../common/trace_explorer';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useFetcher } from '../../../hooks/use_fetcher';
import { useApmRouter } from '../../../hooks/use_apm_router';
import { useApmRoutePath } from '../../../hooks/use_apm_route_path';
import { useTimeRange } from '../../../hooks/use_time_range';
import { TraceExplorerSamplesFetcherContextProvider } from '../../../hooks/use_trace_explorer_samples';
import { APIClientRequestParamsOf } from '../../../services/rest/create_call_apm_api';
import { ApmDatePicker } from '../../shared/date_picker/apm_date_picker';
import { fromQuery, toQuery, push } from '../../shared/links/url_helpers';
import { useWaterfallFetcher } from '../transaction_details/use_waterfall_fetcher';
import { WaterfallWithSummary } from '../transaction_details/waterfall_with_summary';
import { push } from '../../shared/links/url_helpers';
import { TechnicalPreviewBadge } from '../../shared/technical_preview_badge';
import { TransactionTab } from '../transaction_details/waterfall_with_summary/transaction_tabs';
import { TraceSearchBox } from './trace_search_box';
export function TraceExplorer() {
const [query, setQuery] = useState<TraceSearchQuery>({
export function TraceExplorer({ children }: { children: React.ReactElement }) {
const [searchQuery, setSearchQuery] = useState<TraceSearchQuery>({
query: '',
type: TraceSearchType.kql,
});
const {
query,
query: {
rangeFrom,
rangeTo,
environment,
query: queryFromUrlParams,
type: typeFromUrlParams,
traceId,
transactionId,
waterfallItemId,
detailTab,
showCriticalPath,
},
} = useApmParams('/traces/explorer');
const history = useHistory();
useEffect(() => {
setQuery({
setSearchQuery({
query: queryFromUrlParams,
type: typeFromUrlParams,
});
@ -55,120 +55,95 @@ export function TraceExplorer() {
rangeTo,
});
const { data, status, error } = useFetcher(
(callApmApi) => {
return callApmApi('GET /internal/apm/traces/find', {
params: {
query: {
start,
end,
environment,
query: queryFromUrlParams,
type: typeFromUrlParams,
},
},
});
},
[start, end, environment, queryFromUrlParams, typeFromUrlParams]
);
const params = useMemo<
APIClientRequestParamsOf<'GET /internal/apm/traces/find'>['params']
>(() => {
return {
query: {
start,
end,
environment,
query: queryFromUrlParams,
type: typeFromUrlParams,
},
};
}, [start, end, environment, queryFromUrlParams, typeFromUrlParams]);
useEffect(() => {
const nextSample = data?.traceSamples[0];
const nextWaterfallItemId = '';
history.replace({
...history.location,
search: fromQuery({
...toQuery(history.location.search),
traceId: nextSample?.traceId ?? '',
transactionId: nextSample?.transactionId,
waterfallItemId: nextWaterfallItemId,
}),
});
}, [data, history]);
const router = useApmRouter();
const waterfallFetchResult = useWaterfallFetcher({
traceId,
transactionId,
start,
end,
});
const traceSamplesFetchResult = useMemo(
() => ({
data,
status,
error,
}),
[data, status, error]
);
const routePath = useApmRoutePath();
return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiFlexGroup direction="row">
<EuiFlexItem grow>
<TraceSearchBox
query={query}
error={false}
loading={false}
onQueryCommit={() => {
history.push({
...history.location,
search: fromQuery({
...toQuery(history.location.search),
query: query.query,
type: query.type,
}),
});
}}
onQueryChange={(nextQuery) => {
setQuery(nextQuery);
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ApmDatePicker />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<WaterfallWithSummary
waterfallFetchResult={waterfallFetchResult}
traceSamples={traceSamplesFetchResult.data?.traceSamples}
traceSamplesFetchStatus={traceSamplesFetchResult.status}
environment={environment}
onSampleClick={(sample) => {
push(history, {
query: {
traceId: sample.traceId,
transactionId: sample.transactionId,
waterfallItemId: '',
},
});
}}
onTabClick={(nextDetailTab) => {
push(history, {
query: {
detailTab: nextDetailTab,
},
});
}}
detailTab={detailTab}
waterfallItemId={waterfallItemId}
serviceName={
waterfallFetchResult.waterfall.entryWaterfallTransaction?.doc
.service.name
}
showCriticalPath={showCriticalPath}
onShowCriticalPathChange={(nextShowCriticalPath) => {
push(history, {
query: {
showCriticalPath: nextShowCriticalPath ? 'true' : 'false',
},
});
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
<TraceExplorerSamplesFetcherContextProvider params={params}>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="row">
<EuiFlexItem grow>
<TraceSearchBox
query={searchQuery}
error={false}
loading={false}
onQueryCommit={() => {
push(history, {
query: {
query: searchQuery.query,
type: searchQuery.type,
},
});
}}
onQueryChange={(nextQuery) => {
setSearchQuery(nextQuery);
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ApmDatePicker />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiTabs>
<EuiTab
href={router.link('/traces/explorer/waterfall', {
query: {
...query,
traceId: '',
transactionId: '',
waterfallItemId: '',
detailTab: TransactionTab.timeline,
},
})}
isSelected={routePath === '/traces/explorer/waterfall'}
>
{i18n.translate('xpack.apm.traceExplorer.waterfallTab', {
defaultMessage: 'Waterfall',
})}
</EuiTab>
<EuiTab
href={router.link('/traces/explorer/critical_path', {
query,
})}
isSelected={routePath === '/traces/explorer/critical_path'}
>
<EuiFlexGroup direction="row" gutterSize="s">
<EuiFlexItem grow={false}>
{i18n.translate('xpack.apm.traceExplorer.criticalPathTab', {
defaultMessage: 'Aggregated critical path',
})}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<TechnicalPreviewBadge
icon="beaker"
size="s"
style={{ verticalAlign: 'middle' }}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiTab>
</EuiTabs>
</EuiFlexItem>
<EuiFlexItem>{children}</EuiFlexItem>
</EuiFlexGroup>
</TraceExplorerSamplesFetcherContextProvider>
);
}

View file

@ -0,0 +1,37 @@
/*
* 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 React, { useMemo } from 'react';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useTimeRange } from '../../../hooks/use_time_range';
import { useTraceExplorerSamples } from '../../../hooks/use_trace_explorer_samples';
import { CriticalPathFlamegraph } from '../../shared/critical_path_flamegraph';
export function TraceExplorerAggregatedCriticalPath() {
const {
query: { rangeFrom, rangeTo },
} = useApmParams('/traces/explorer/critical_path');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const {
data: { traceSamples },
status: samplesFetchStatus,
} = useTraceExplorerSamples();
const traceIds = useMemo(() => {
return traceSamples.map((sample) => sample.traceId);
}, [traceSamples]);
return (
<CriticalPathFlamegraph
start={start}
end={end}
traceIds={traceIds}
traceIdsFetchStatus={samplesFetchStatus}
/>
);
}

View file

@ -0,0 +1,93 @@
/*
* 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 React, { useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useTimeRange } from '../../../hooks/use_time_range';
import { useTraceExplorerSamples } from '../../../hooks/use_trace_explorer_samples';
import { push, replace } from '../../shared/links/url_helpers';
import { useWaterfallFetcher } from '../transaction_details/use_waterfall_fetcher';
import { WaterfallWithSummary } from '../transaction_details/waterfall_with_summary';
export function TraceExplorerWaterfall() {
const history = useHistory();
const traceSamplesFetchResult = useTraceExplorerSamples();
const {
query: {
traceId,
transactionId,
waterfallItemId,
rangeFrom,
rangeTo,
environment,
showCriticalPath,
detailTab,
},
} = useApmParams('/traces/explorer/waterfall');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
useEffect(() => {
const nextSample = traceSamplesFetchResult.data?.traceSamples[0];
const nextWaterfallItemId = '';
replace(history, {
query: {
traceId: nextSample?.traceId ?? '',
transactionId: nextSample?.transactionId ?? '',
waterfallItemId: nextWaterfallItemId,
},
});
}, [traceSamplesFetchResult.data, history]);
const waterfallFetchResult = useWaterfallFetcher({
traceId,
transactionId,
start,
end,
});
return (
<WaterfallWithSummary
waterfallFetchResult={waterfallFetchResult}
traceSamples={traceSamplesFetchResult.data.traceSamples}
traceSamplesFetchStatus={traceSamplesFetchResult.status}
environment={environment}
onSampleClick={(sample) => {
push(history, {
query: {
traceId: sample.traceId,
transactionId: sample.transactionId,
waterfallItemId: '',
},
});
}}
onTabClick={(nextDetailTab) => {
push(history, {
query: {
detailTab: nextDetailTab,
},
});
}}
detailTab={detailTab}
waterfallItemId={waterfallItemId}
serviceName={
waterfallFetchResult.waterfall.entryWaterfallTransaction?.doc.service
.name
}
showCriticalPath={showCriticalPath}
onShowCriticalPathChange={(nextShowCriticalPath) => {
push(history, {
query: {
showCriticalPath: nextShowCriticalPath ? 'true' : 'false',
},
});
}}
/>
);
}

View file

@ -4,16 +4,22 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useApmRouter } from '../../../hooks/use_apm_router';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useApmRoutePath } from '../../../hooks/use_apm_route_path';
import React from 'react';
import { TraceSearchType } from '../../../../common/trace_explorer';
import { TransactionTab } from '../transaction_details/waterfall_with_summary/transaction_tabs';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useApmRouter } from '../../../hooks/use_apm_router';
import { useApmRoutePath } from '../../../hooks/use_apm_route_path';
import { useTraceExplorerEnabledSetting } from '../../../hooks/use_trace_explorer_enabled_setting';
import { ApmMainTemplate } from '../../routing/templates/apm_main_template';
import { TechnicalPreviewBadge } from '../../shared/technical_preview_badge';
import { Breadcrumb } from '../breadcrumb';
import { TransactionTab } from '../transaction_details/waterfall_with_summary/transaction_tabs';
type Tab = Required<
Required<React.ComponentProps<typeof ApmMainTemplate>>['pageHeader']
>['tabs'][number];
export function TraceOverview({ children }: { children: React.ReactElement }) {
const isTraceExplorerEnabled = useTraceExplorerEnabledSetting();
@ -24,11 +30,24 @@ export function TraceOverview({ children }: { children: React.ReactElement }) {
const routePath = useApmRoutePath();
if (!isTraceExplorerEnabled) {
return children;
}
const topTracesLink = router.link('/traces', {
query: {
comparisonEnabled: query.comparisonEnabled,
environment: query.environment,
kuery: query.kuery,
rangeFrom: query.rangeFrom,
rangeTo: query.rangeTo,
offset: query.offset,
refreshInterval: query.refreshInterval,
refreshPaused: query.refreshPaused,
},
});
const explorerLink = router.link('/traces/explorer', {
const title = i18n.translate('xpack.apm.views.traceOverview.title', {
defaultMessage: 'Traces',
});
const explorerLink = router.link('/traces/explorer/waterfall', {
query: {
comparisonEnabled: query.comparisonEnabled,
environment: query.environment,
@ -48,40 +67,55 @@ export function TraceOverview({ children }: { children: React.ReactElement }) {
},
});
const topTracesLink = router.link('/traces', {
query: {
comparisonEnabled: query.comparisonEnabled,
environment: query.environment,
kuery: query.kuery,
rangeFrom: query.rangeFrom,
rangeTo: query.rangeTo,
offset: query.offset,
refreshInterval: query.refreshInterval,
refreshPaused: query.refreshPaused,
},
});
const tabs: Tab[] = isTraceExplorerEnabled
? [
{
href: topTracesLink,
label: i18n.translate('xpack.apm.traceOverview.topTracesTab', {
defaultMessage: 'Top traces',
}),
isSelected: routePath === '/traces',
},
{
href: explorerLink,
label: (
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
{i18n.translate('xpack.apm.traceOverview.traceExplorerTab', {
defaultMessage: 'Explorer',
})}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<TechnicalPreviewBadge
icon="beaker"
style={{ verticalAlign: 'middle' }}
/>
</EuiFlexItem>
</EuiFlexGroup>
),
isSelected: routePath.startsWith('/traces/explorer'),
},
]
: [];
return (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiTabs size="l">
<EuiTab href={topTracesLink} isSelected={routePath === '/traces'}>
{i18n.translate('xpack.apm.traceOverview.topTracesTab', {
defaultMessage: 'Top traces',
})}
</EuiTab>
<EuiTab
href={explorerLink}
append={<TechnicalPreviewBadge icon="beaker" />}
isSelected={routePath === '/traces/explorer'}
>
{i18n.translate('xpack.apm.traceOverview.traceExplorerTab', {
defaultMessage: 'Explorer',
})}
</EuiTab>
</EuiTabs>
</EuiFlexItem>
<EuiFlexItem>{children}</EuiFlexItem>
</EuiFlexGroup>
<Breadcrumb href="/traces" title={title}>
<ApmMainTemplate
pageTitle={title}
pageSectionProps={{
contentProps: {
style: {
display: 'flex',
flexGrow: 1,
},
},
}}
pageHeader={{
tabs,
}}
>
{children}
</ApmMainTemplate>
</Breadcrumb>
);
}

View file

@ -0,0 +1,70 @@
/*
* 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 } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useTimeRange } from '../../../hooks/use_time_range';
import { CriticalPathFlamegraph } from '../../shared/critical_path_flamegraph';
import { TechnicalPreviewBadge } from '../../shared/technical_preview_badge';
import { TabContentProps } from './transaction_details_tabs';
function TransactionDetailAggregatedCriticalPath({
traceSamplesFetchResult,
}: TabContentProps) {
const {
path: { serviceName },
query: { rangeFrom, rangeTo, transactionName },
} = useApmParams('/services/{serviceName}/transactions/view');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const traceIds = useMemo(() => {
return (
traceSamplesFetchResult.data?.traceSamples.map(
(sample) => sample.traceId
) ?? []
);
}, [traceSamplesFetchResult.data]);
return (
<CriticalPathFlamegraph
start={start}
end={end}
traceIdsFetchStatus={traceSamplesFetchResult.status}
traceIds={traceIds}
serviceName={serviceName}
transactionName={transactionName}
/>
);
}
export const aggregatedCriticalPathTab = {
dataTestSubj: 'apmAggregatedCriticalPathTabButton',
key: 'aggregatedCriticalPath',
label: (
<EuiFlexGroup gutterSize="s" direction="row">
<EuiFlexItem grow={false}>
{i18n.translate(
'xpack.apm.transactionDetails.tabs.aggregatedCriticalPathLabel',
{
defaultMessage: 'Aggregated critical path',
}
)}
</EuiFlexItem>
<EuiFlexItem>
<TechnicalPreviewBadge
icon="beaker"
size="s"
style={{ verticalAlign: 'middle' }}
/>
</EuiFlexItem>
</EuiFlexGroup>
),
component: TransactionDetailAggregatedCriticalPath,
};

View file

@ -27,6 +27,8 @@ import { latencyCorrelationsTab } from './latency_correlations_tab';
import { traceSamplesTab } from './trace_samples_tab';
import { useSampleChartSelection } from '../../../hooks/use_sample_chart_selection';
import { FETCH_STATUS } from '../../../hooks/use_fetcher';
import { useCriticalPathFeatureEnabledSetting } from '../../../hooks/use_critical_path_feature_enabled_setting';
import { aggregatedCriticalPathTab } from './aggregated_critical_path_tab';
export interface TabContentProps {
clearChartSelection: () => void;
@ -46,12 +48,18 @@ const tabs = [
export function TransactionDetailsTabs() {
const { query } = useApmParams('/services/{serviceName}/transactions/view');
const isCriticalPathFeatureEnabled = useCriticalPathFeatureEnabledSetting();
const availableTabs = isCriticalPathFeatureEnabled
? tabs.concat(aggregatedCriticalPathTab)
: tabs;
const { urlParams } = useLegacyUrlParams();
const history = useHistory();
const [currentTab, setCurrentTab] = useState(traceSamplesTab.key);
const { component: TabContent } =
tabs.find((tab) => tab.key === currentTab) ?? traceSamplesTab;
availableTabs.find((tab) => tab.key === currentTab) ?? traceSamplesTab;
const { environment, kuery, transactionName } = query;
@ -107,7 +115,7 @@ export function TransactionDetailsTabs() {
return (
<>
<EuiTabs>
{tabs.map(({ dataTestSubj, key, label }) => (
{availableTabs.map(({ dataTestSubj, key, label }) => (
<EuiTab
data-test-subj={dataTestSubj}
key={key}

View file

@ -104,80 +104,85 @@ export function WaterfallWithSummary<TSample extends {}>({
const entryTransaction = entryWaterfallTransaction?.doc;
return (
<>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h5>
{i18n.translate('xpack.apm.transactionDetails.traceSampleTitle', {
defaultMessage: 'Trace sample',
})}
</h5>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
{!!traceSamples?.length && (
<EuiPagination
pageCount={traceSamples.length}
activePage={samplePageIndex}
onPageClick={goToSample}
compressed
/>
)}
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<TransactionActionMenu
isLoading={isLoading}
transaction={entryTransaction}
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h5>
{i18n.translate(
'xpack.apm.transactionDetails.traceSampleTitle',
{
defaultMessage: 'Trace sample',
}
)}
</h5>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow>
{!!traceSamples?.length && (
<EuiPagination
pageCount={traceSamples.length}
activePage={samplePageIndex}
onPageClick={goToSample}
compressed
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<MaybeViewTraceLink
isLoading={isLoading}
transaction={entryTransaction}
waterfall={waterfallFetchResult.waterfall}
environment={environment}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<TransactionActionMenu
isLoading={isLoading}
transaction={entryTransaction}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<MaybeViewTraceLink
isLoading={isLoading}
transaction={entryTransaction}
waterfall={waterfallFetchResult.waterfall}
environment={environment}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{isLoading || !entryTransaction ? (
<>
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />
<EuiLoadingContent lines={1} data-test-sub="loading-content" />
</>
</EuiFlexItem>
) : (
<TransactionSummary
errorCount={
waterfallFetchResult.waterfall.apiResponse.errorDocs.length
}
totalDuration={
waterfallFetchResult.waterfall.rootTransaction?.transaction.duration
.us
}
transaction={entryTransaction}
/>
<EuiFlexItem grow={false}>
<TransactionSummary
errorCount={
waterfallFetchResult.waterfall.apiResponse.errorDocs.length
}
totalDuration={
waterfallFetchResult.waterfall.rootTransaction?.transaction
.duration.us
}
transaction={entryTransaction}
/>
</EuiFlexItem>
)}
<EuiSpacer size="s" />
<TransactionTabs
transaction={entryTransaction}
detailTab={detailTab}
serviceName={serviceName}
waterfallItemId={waterfallItemId}
onTabClick={onTabClick}
waterfall={waterfallFetchResult.waterfall}
isLoading={isLoading}
showCriticalPath={showCriticalPath}
onShowCriticalPathChange={onShowCriticalPathChange}
/>
</>
<EuiFlexItem grow={false}>
<TransactionTabs
transaction={entryTransaction}
detailTab={detailTab}
serviceName={serviceName}
waterfallItemId={waterfallItemId}
onTabClick={onTabClick}
waterfall={waterfallFetchResult.waterfall}
isLoading={isLoading}
showCriticalPath={showCriticalPath}
onShowCriticalPathChange={onShowCriticalPathChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -19,6 +19,8 @@ import { ServiceInventory } from '../../app/service_inventory';
import { ServiceMapHome } from '../../app/service_map';
import { TopTracesOverview } from '../../app/top_traces_overview';
import { TraceExplorer } from '../../app/trace_explorer';
import { TraceExplorerAggregatedCriticalPath } from '../../app/trace_explorer/trace_explorer_aggregated_critical_path';
import { TraceExplorerWaterfall } from '../../app/trace_explorer/trace_explorer_waterfall';
import { TraceOverview } from '../../app/trace_overview';
import { TransactionTab } from '../../app/transaction_details/waterfall_with_summary/transaction_tabs';
import { RedirectTo } from '../redirect_to';
@ -184,11 +186,7 @@ export const home = {
element: <ServiceMapHome />,
serviceGroupContextTab: 'service-map',
}),
...page({
path: '/traces',
title: i18n.translate('xpack.apm.views.traceOverview.title', {
defaultMessage: 'Traces',
}),
'/traces': {
element: (
<TraceOverview>
<Outlet />
@ -196,7 +194,42 @@ export const home = {
),
children: {
'/traces/explorer': {
element: <TraceExplorer />,
element: (
<TraceExplorer>
<Outlet />
</TraceExplorer>
),
children: {
'/traces/explorer/waterfall': {
element: <TraceExplorerWaterfall />,
params: t.type({
query: t.type({
traceId: t.string,
transactionId: t.string,
waterfallItemId: t.string,
detailTab: t.union([
t.literal(TransactionTab.timeline),
t.literal(TransactionTab.metadata),
t.literal(TransactionTab.logs),
]),
}),
}),
defaults: {
query: {
waterfallItemId: '',
traceId: '',
transactionId: '',
detailTab: TransactionTab.timeline,
},
},
},
'/traces/explorer/critical_path': {
element: <TraceExplorerAggregatedCriticalPath />,
},
'/traces/explorer': {
element: <RedirectTo pathname="/traces/explorer/waterfall" />,
},
},
params: t.type({
query: t.type({
query: t.string,
@ -204,14 +237,6 @@ export const home = {
t.literal(TraceSearchType.kql),
t.literal(TraceSearchType.eql),
]),
waterfallItemId: t.string,
traceId: t.string,
transactionId: t.string,
detailTab: t.union([
t.literal(TransactionTab.timeline),
t.literal(TransactionTab.metadata),
t.literal(TransactionTab.logs),
]),
showCriticalPath: toBooleanRt,
}),
}),
@ -219,10 +244,6 @@ export const home = {
query: {
query: '',
type: TraceSearchType.kql,
waterfallItemId: '',
traceId: '',
transactionId: '',
detailTab: TransactionTab.timeline,
showCriticalPath: '',
},
},
@ -231,7 +252,7 @@ export const home = {
element: <TopTracesOverview />,
},
},
}),
},
...dependencies,
...legacyBackends,
...storageExplorer,

View file

@ -6,17 +6,18 @@
*/
import { EuiPageHeaderProps } from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { ObservabilityPageTemplateProps } from '@kbn/observability-plugin/public/components/shared/page_template/page_template';
import type { KibanaPageTemplateProps } from '@kbn/shared-ux-page-kibana-template';
import React from 'react';
import { useLocation } from 'react-router-dom';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { KibanaPageTemplateProps } from '@kbn/shared-ux-page-kibana-template';
import { EnvironmentsContextProvider } from '../../../context/environments_context/environments_context';
import { useFetcher, FETCH_STATUS } from '../../../hooks/use_fetcher';
import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher';
import { ApmPluginStartDeps } from '../../../plugin';
import { ApmEnvironmentFilter } from '../../shared/environment_filter';
import { getNoDataConfig } from './no_data_config';
import { ServiceGroupSaveButton } from '../../app/service_groups';
import { ServiceGroupsButtonGroup } from '../../app/service_groups/service_groups_button_group';
import { ApmEnvironmentFilter } from '../../shared/environment_filter';
import { getNoDataConfig } from './no_data_config';
// Paths that must skip the no data screen
const bypassNoDataScreenPaths = ['/settings'];
@ -48,7 +49,8 @@ export function ApmMainTemplate({
showServiceGroupSaveButton?: boolean;
showServiceGroupsNav?: boolean;
selectedNavButton?: 'serviceGroups' | 'allServices';
} & KibanaPageTemplateProps) {
} & KibanaPageTemplateProps &
Pick<ObservabilityPageTemplateProps, 'pageSectionProps'>) {
const location = useLocation();
const { services } = useKibana<ApmPluginStartDeps>();

View file

@ -0,0 +1,120 @@
/*
* 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 {
EuiBadge,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiPanel,
} from '@elastic/eui';
import React from 'react';
import { ProcessorEvent } from '@kbn/observability-plugin/common';
import { i18n } from '@kbn/i18n';
import type { CriticalPathResponse } from '../../../../server/routes/traces/get_aggregated_critical_path';
import {
AGENT_NAME,
SERVICE_NAME,
SPAN_NAME,
SPAN_SUBTYPE,
SPAN_TYPE,
TRANSACTION_NAME,
TRANSACTION_TYPE,
} from '../../../../common/elasticsearch_fieldnames';
import { SpanIcon } from '../span_icon';
import { AgentIcon } from '../agent_icon';
import { asPercent } from '../../../../common/utils/formatters';
export function CriticalPathFlamegraphTooltip({
metadata,
countInclusive,
countExclusive,
totalCount,
}: {
metadata?: CriticalPathResponse['metadata'][string];
countInclusive: number;
countExclusive: number;
totalCount: number;
}) {
if (!metadata) {
return <></>;
}
return (
<EuiPanel>
<EuiFlexGroup direction="column" gutterSize="s">
{metadata['processor.event'] === ProcessorEvent.transaction ? (
<EuiFlexItem grow={false}>
<EuiFlexGroup
direction="row"
gutterSize="s"
style={{ overflowWrap: 'anywhere' }}
alignItems="center"
>
<EuiFlexItem grow={false}>
{metadata[TRANSACTION_NAME]}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge>{metadata[TRANSACTION_TYPE]}</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
) : (
<EuiFlexItem>
<EuiFlexGroup
direction="row"
gutterSize="s"
style={{ overflowWrap: 'anywhere' }}
alignItems="center"
>
<EuiFlexItem grow={false}>
<SpanIcon
type={metadata[SPAN_TYPE]}
subtype={metadata[SPAN_SUBTYPE]}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>{metadata[SPAN_NAME]}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiHorizontalRule margin="none" />
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<AgentIcon agentName={metadata[AGENT_NAME]} />
</EuiFlexItem>
<EuiFlexItem grow={false}>{metadata[SERVICE_NAME]}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiHorizontalRule margin="none" />
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={false}>
{i18n.translate('xpack.apm.criticalPathFlameGraph.selfTime', {
defaultMessage: 'Self time: {value}',
values: {
value: asPercent(countExclusive / totalCount, 1),
},
})}
</EuiFlexItem>
<EuiFlexItem grow={false}>
{i18n.translate('xpack.apm.criticalPathFlameGraph.totalTime', {
defaultMessage: 'Total time: {value}',
values: {
value: asPercent(countInclusive / totalCount, 1),
},
})}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}

View file

@ -0,0 +1,144 @@
/*
* 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 type { ColumnarViewModel } from '@elastic/charts';
import { memoize, sumBy } from 'lodash';
import { lighten, parseToRgb } from 'polished';
import seedrandom from 'seedrandom';
import type { CriticalPathResponse } from '../../../../server/routes/traces/get_aggregated_critical_path';
import {
CriticalPathTreeNode,
getAggregatedCriticalPathRootNodes,
} from '../../../../common/critical_path/get_aggregated_critical_path_root_nodes';
const lightenColor = lighten(0.2);
export function criticalPathToFlamegraph(
params: {
criticalPath: CriticalPathResponse;
colors: string[];
} & ({ serviceName: string; transactionName: string } | {})
): {
viewModel: ColumnarViewModel;
operationId: string[];
countExclusive: Float64Array;
sum: number;
} {
let sum = 0;
const { criticalPath, colors } = params;
const { rootNodes, maxDepth, numNodes } =
getAggregatedCriticalPathRootNodes(params);
// include the root node
const totalSize = numNodes + 1;
const operationId = new Array<string>(totalSize);
const countInclusive = new Float64Array(totalSize);
const countExclusive = new Float64Array(totalSize);
const label = new Array<string>(totalSize);
const position = new Float32Array(totalSize * 2);
const size = new Float32Array(totalSize);
const color = new Float32Array(totalSize * 4);
// eslint-disable-next-line guard-for-in
for (const nodeId in criticalPath.timeByNodeId) {
const count = criticalPath.timeByNodeId[nodeId];
sum += count;
}
let maxValue = 0;
let index = 0;
const availableColors: Array<[number, number, number, number]> = colors.map(
(vizColor) => {
const rgb = parseToRgb(lightenColor(vizColor));
return [rgb.red / 255, rgb.green / 255, rgb.blue / 255, 1];
}
);
const pickColor = memoize((identifier: string) => {
const idx =
Math.abs(seedrandom(identifier).int32()) % availableColors.length;
return availableColors[idx];
});
function addNodeToFlamegraph(
node: CriticalPathTreeNode,
x: number,
y: number
) {
let nodeOperationId: string;
let nodeLabel: string;
let operationMetadata: CriticalPathResponse['metadata'][string] | undefined;
if (node.nodeId === 'root') {
nodeOperationId = '';
nodeLabel = 'root';
} else {
nodeOperationId = criticalPath.operationIdByNodeId[node.nodeId];
operationMetadata = criticalPath.metadata[nodeOperationId];
nodeLabel =
operationMetadata['processor.event'] === 'transaction'
? operationMetadata['transaction.name']
: operationMetadata['span.name'];
}
operationId[index] = nodeOperationId;
countInclusive[index] = node.countInclusive;
countExclusive[index] = node.countExclusive;
label[index] = nodeLabel;
position[index * 2] = x / maxValue;
position[index * 2 + 1] = 1 - (y + 1) / (maxDepth + 1);
size[index] = node.countInclusive / maxValue;
const identifier =
operationMetadata?.['processor.event'] === 'transaction'
? operationMetadata['transaction.type']
: operationMetadata?.['span.subtype'] ||
operationMetadata?.['span.type'] ||
'';
color.set(pickColor(identifier), index * 4);
index++;
let childX = x;
node.children.forEach((child) => {
addNodeToFlamegraph(child, childX, y + 1);
childX += child.countInclusive;
});
}
const root: CriticalPathTreeNode = {
children: rootNodes,
nodeId: 'root',
countExclusive: 0,
countInclusive: sumBy(rootNodes, 'countInclusive'),
};
maxValue = root.countInclusive;
addNodeToFlamegraph(root, 0, 0);
return {
viewModel: {
value: countInclusive,
label,
color,
position0: position,
position1: position,
size0: size,
size1: size,
},
operationId,
countExclusive,
sum,
};
}

View file

@ -0,0 +1,165 @@
/*
* 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, Flame, Settings } from '@elastic/charts';
import {
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
euiPaletteColorBlind,
} from '@elastic/eui';
import { css } from '@emotion/css';
import { useChartTheme } from '@kbn/observability-plugin/public';
import { uniqueId } from 'lodash';
import React, { useMemo, useRef } from 'react';
import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher';
import { CriticalPathFlamegraphTooltip } from './critical_path_flamegraph_tooltip';
import { criticalPathToFlamegraph } from './critical_path_to_flamegraph';
const chartClassName = css`
flex-grow: 1;
`;
export function CriticalPathFlamegraph(
props: {
start: string;
end: string;
traceIds: string[];
traceIdsFetchStatus: FETCH_STATUS;
} & ({ serviceName: string; transactionName: string } | {})
) {
const { start, end, traceIds, traceIdsFetchStatus } = props;
const serviceName = 'serviceName' in props ? props.serviceName : null;
const transactionName =
'transactionName' in props ? props.transactionName : null;
// Use a reference to time range, to not invalidate the API fetch
// we only care for traceIds, start/end are there to limit the search
// request to a certain time range. It shouldn't affect the actual results
// of the search.
const timerange = useRef({ start, end });
timerange.current = { start, end };
const {
data: { criticalPath } = { criticalPath: null },
status: criticalPathFetchStatus,
} = useFetcher(
(callApmApi) => {
if (!traceIds.length) {
return Promise.resolve({ criticalPath: null });
}
return callApmApi('POST /internal/apm/traces/aggregated_critical_path', {
params: {
body: {
start: timerange.current.start,
end: timerange.current.end,
traceIds,
serviceName,
transactionName,
},
},
});
},
[timerange, traceIds, serviceName, transactionName]
);
const chartTheme = useChartTheme();
const isLoading =
traceIdsFetchStatus === FETCH_STATUS.NOT_INITIATED ||
traceIdsFetchStatus === FETCH_STATUS.LOADING ||
criticalPathFetchStatus === FETCH_STATUS.NOT_INITIATED ||
criticalPathFetchStatus === FETCH_STATUS.LOADING;
const flameGraph = useMemo(() => {
if (!criticalPath) {
return undefined;
}
const colors = euiPaletteColorBlind({});
const flamegraph = criticalPathToFlamegraph({
criticalPath,
colors,
});
return {
...flamegraph,
// make sure Flame re-renders when data changes, workaround for https://github.com/elastic/elastic-charts/issues/1766
key: uniqueId(),
};
}, [criticalPath]);
const themeOverrides = {
chartMargins: { top: 0, left: 0, bottom: 0, right: 0 },
chartPaddings: { left: 0, right: 0, top: 0, bottom: 0 },
};
return (
<EuiFlexGroup
direction="column"
gutterSize="l"
alignItems="stretch"
justifyContent="center"
style={{ minHeight: 400 }}
>
{isLoading ? (
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<EuiLoadingSpinner size="l" />
</EuiFlexItem>
) : (
flameGraph && (
<EuiFlexItem grow>
<Chart key={flameGraph.key} className={chartClassName}>
<Settings
theme={[
{
chartMargins: themeOverrides.chartMargins,
chartPaddings: themeOverrides.chartPaddings,
},
...chartTheme,
]}
tooltip={{
customTooltip: (tooltipProps) => {
const valueIndex = tooltipProps.values[0]
.valueAccessor as number;
const operationId = flameGraph.operationId[valueIndex];
const operationMetadata =
criticalPath?.metadata[operationId];
const countInclusive =
flameGraph.viewModel.value[valueIndex];
const countExclusive =
flameGraph.countExclusive[valueIndex];
return (
<CriticalPathFlamegraphTooltip
metadata={operationMetadata}
countInclusive={countInclusive}
countExclusive={countExclusive}
totalCount={flameGraph.viewModel.value[0]}
/>
);
},
}}
onElementClick={(elements) => {}}
/>
<Flame
id="aggregated_critical_path"
columnarData={flameGraph.viewModel}
valueAccessor={(d: Datum) => d.value as number}
valueFormatter={(value) => `${value}`}
animation={{ duration: 100 }}
controlProviderCallback={{}}
/>
</Chart>
</EuiFlexItem>
)
)}
</EuiFlexGroup>
);
}

View file

@ -9,11 +9,11 @@ import { EuiBetaBadge, IconType } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
interface Props {
type Props = {
icon?: IconType;
}
} & Pick<React.ComponentProps<typeof EuiBetaBadge>, 'size' | 'style'>;
export function TechnicalPreviewBadge({ icon }: Props) {
export function TechnicalPreviewBadge({ icon, size, style }: Props) {
return (
<EuiBetaBadge
label={i18n.translate('xpack.apm.technicalPreviewBadgeLabel', {
@ -27,6 +27,8 @@ export function TechnicalPreviewBadge({ icon }: Props) {
}
)}
iconType={icon}
size={size}
style={style}
/>
);
}

View file

@ -0,0 +1,72 @@
/*
* 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 React, { createContext, useContext, useMemo } from 'react';
import type { APIEndpoint } from '../../server';
import type {
APIClientRequestParamsOf,
APIReturnType,
} from '../services/rest/create_call_apm_api';
import { useFetcher, FetcherResult } from './use_fetcher';
interface SharedUseFetcher<TEndpoint extends APIEndpoint> {
useFetcherResult: () => FetcherResult<APIReturnType<TEndpoint>> & {
refetch: () => void;
};
Provider: React.FunctionComponent<
{
children: React.ReactElement;
params: {};
} & APIClientRequestParamsOf<TEndpoint>
>;
}
export function createSharedUseFetcher<TEndpoint extends APIEndpoint>(
endpoint: TEndpoint
): SharedUseFetcher<TEndpoint> {
const Context = createContext<
APIClientRequestParamsOf<APIEndpoint> | undefined
>(undefined);
const returnValue: SharedUseFetcher<TEndpoint> = {
useFetcherResult: () => {
const context = useContext(Context);
if (!context) {
throw new Error('Context was not found');
}
const params = context.params;
const result = useFetcher(
(callApmApi) => {
return callApmApi(
...([endpoint, { params }] as Parameters<typeof callApmApi>)
);
},
[params]
);
return result as ReturnType<
SharedUseFetcher<TEndpoint>['useFetcherResult']
>;
},
Provider: (props) => {
const { children } = props;
const params = props.params;
const memoizedParams = useMemo(() => {
return { params };
}, [params]);
return (
<Context.Provider value={memoizedParams}>{children}</Context.Provider>
);
},
};
return returnValue;
}

View file

@ -0,0 +1,29 @@
/*
* 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 { useMemo } from 'react';
import { createSharedUseFetcher } from './create_shared_use_fetcher';
const sharedUseFetcher = createSharedUseFetcher(
'GET /internal/apm/traces/find'
);
const useTraceExplorerSamples = () => {
const result = sharedUseFetcher.useFetcherResult();
return useMemo(() => {
return {
...result,
data: result.data || {
traceSamples: [],
},
};
}, [result]);
};
const TraceExplorerSamplesFetcherContextProvider = sharedUseFetcher.Provider;
export { useTraceExplorerSamples, TraceExplorerSamplesFetcherContextProvider };

View file

@ -0,0 +1,406 @@
/*
* 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 { ProcessorEvent } from '@kbn/observability-plugin/common';
import { rangeQuery, termsQuery } from '@kbn/observability-plugin/server';
import { Logger } from '@kbn/logging';
import {
AGENT_NAME,
PROCESSOR_EVENT,
SERVICE_NAME,
SPAN_NAME,
SPAN_SUBTYPE,
SPAN_TYPE,
TRACE_ID,
TRANSACTION_NAME,
TRANSACTION_TYPE,
} from '../../../common/elasticsearch_fieldnames';
import { AgentName } from '../../../typings/es_schemas/ui/fields/agent';
import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client';
type OperationMetadata = {
[SERVICE_NAME]: string;
[AGENT_NAME]: AgentName;
} & (
| {
[PROCESSOR_EVENT]: ProcessorEvent.transaction;
[TRANSACTION_TYPE]: string;
[TRANSACTION_NAME]: string;
}
| {
[PROCESSOR_EVENT]: ProcessorEvent.span;
[SPAN_NAME]: string;
[SPAN_TYPE]: string;
[SPAN_SUBTYPE]: string;
}
);
type OperationId = string;
type NodeId = string;
export interface CriticalPathResponse {
metadata: Record<OperationId, OperationMetadata>;
timeByNodeId: Record<NodeId, number>;
nodes: Record<NodeId, NodeId[]>;
rootNodes: NodeId[];
operationIdByNodeId: Record<NodeId, OperationId>;
}
const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
export async function getAggregatedCriticalPath({
traceIds,
start,
end,
apmEventClient,
serviceName,
transactionName,
logger,
}: {
traceIds: string[];
start: number;
end: number;
apmEventClient: APMEventClient;
serviceName: string | null;
transactionName: string | null;
logger: Logger;
}): Promise<{ criticalPath: CriticalPathResponse | null }> {
const now = Date.now();
const response = await apmEventClient.search('get_aggregated_critical_path', {
apm: {
events: [ProcessorEvent.span, ProcessorEvent.transaction],
},
body: {
size: 0,
track_total_hits: false,
query: {
bool: {
filter: [
...termsQuery(TRACE_ID, ...traceIds),
// we need a range query to allow ES to skip shards based on the time range,
// but we need enough padding to make sure we get the full trace
...rangeQuery(start - TWO_DAYS_MS, end + TWO_DAYS_MS),
],
},
},
aggs: {
critical_path: {
scripted_metric: {
params: {
// can't send null parameters to ES. undefined will be removed during JSON serialisation
serviceName: serviceName || undefined,
transactionName: transactionName || undefined,
},
init_script: {
source: `
state.eventsById = [:];
state.metadataByOperationId = [:];
`,
},
map_script: {
source: `
String toHash (def item) {
return item.toString();
}
def id;
double duration;
def operationMetadata = [
"service.name": doc['service.name'].value,
"processor.event": doc['processor.event'].value,
"agent.name": doc['agent.name'].value
];
def isSpan = !doc['span.id'].empty;
if (isSpan) {
id = doc['span.id'].value;
operationMetadata.put('span.name', doc['span.name'].value);
if (!doc['span.type'].empty) {
operationMetadata.put('span.type', doc['span.type'].value);
}
if (!doc['span.subtype'].empty) {
operationMetadata.put('span.subtype', doc['span.subtype'].value);
}
duration = doc['span.duration.us'].value;
} else {
id = doc['transaction.id'].value;
operationMetadata.put('transaction.name', doc['transaction.name'].value);
operationMetadata.put('transaction.type', doc['transaction.type'].value);
duration = doc['transaction.duration.us'].value;
}
String operationId = toHash(operationMetadata);
def map = [
"traceId": doc['trace.id'].value,
"id": id,
"parentId": doc['parent.id'].empty ? null : doc['parent.id'].value,
"operationId": operationId,
"timestamp": doc['timestamp.us'].value,
"duration": duration
];
if (state.metadataByOperationId[operationId] == null) {
state.metadataByOperationId.put(operationId, operationMetadata);
}
state.eventsById.put(id, map);
`,
},
combine_script: {
source: 'return state;',
},
reduce_script: {
source: `
String toHash (def item) {
return item.toString();
}
def processEvent (def context, def event) {
if (context.processedEvents[event.id] != null) {
return context.processedEvents[event.id];
}
def processedEvent = [
"children": []
];
if(event.parentId != null) {
def parent = context.events[event.parentId];
if (parent == null) {
return null;
}
def processedParent = processEvent(context, parent);
if (processedParent == null) {
return null;
}
processedParent.children.add(processedEvent);
}
context.processedEvents.put(event.id, processedEvent);
processedEvent.putAll(event);
if (context.params.serviceName != null && context.params.transactionName != null) {
def metadata = context.metadata[event.operationId];
if (metadata != null
&& context.params.serviceName == metadata['service.name']
&& metadata['transaction.name'] != null
&& context.params.transactionName == metadata['transaction.name']
) {
context.entryTransactions.add(processedEvent);
}
} else if (event.parentId == null) {
context.entryTransactions.add(processedEvent);
}
return processedEvent;
}
double getClockSkew (def context, def item, def parent ) {
if (parent == null) {
return 0;
}
def processorEvent = context.metadata[item.operationId]['processor.event'];
def isTransaction = processorEvent == 'transaction';
if (!isTransaction) {
return parent.skew;
}
double parentStart = parent.timestamp + parent.skew;
double offsetStart = parentStart - item.timestamp;
if (offsetStart > 0) {
double latency = Math.round(Math.max(parent.duration - item.duration, 0) / 2);
return offsetStart + latency;
}
return 0;
}
void setOffsetAndSkew ( def context, def event, def parent, def startOfTrace ) {
event.skew = getClockSkew(context, event, parent);
event.offset = event.timestamp - startOfTrace;
for(child in event.children) {
setOffsetAndSkew(context, child, event, startOfTrace);
}
event.end = event.offset + event.skew + event.duration;
}
void count ( def context, def nodeId, def duration ) {
context.timeByNodeId[nodeId] = (context.timeByNodeId[nodeId] ?: 0) + duration;
}
void scan ( def context, def item, def start, def end, def path ) {
def nodeId = toHash(path);
def childNodes = context.nodes[nodeId] != null ? context.nodes[nodeId] : [];
context.nodes[nodeId] = childNodes;
context.operationIdByNodeId[nodeId] = item.operationId;
if (item.children.size() == 0) {
count(context, nodeId, end - start);
return;
}
item.children.sort((a, b) -> {
if (b.end === a.end) {
return 0;
}
if (b.end > a.end) {
return 1;
}
return -1;
});
def scanTime = end;
for(child in item.children) {
double normalizedChildStart = Math.max(child.offset + child.skew, start);
double childEnd = child.offset + child.skew + child.duration;
double normalizedChildEnd = Math.min(childEnd, scanTime);
def isOnCriticalPath = !(
normalizedChildStart >= scanTime ||
normalizedChildEnd < start ||
childEnd > scanTime
);
if (!isOnCriticalPath) {
continue;
}
def childPath = path.clone();
childPath.add(child.operationId);
def childId = toHash(childPath);
if(!childNodes.contains(childId)) {
childNodes.add(childId);
}
if (normalizedChildEnd < (scanTime - 1000)) {
count(context, nodeId, scanTime - normalizedChildEnd);
}
scan(context, child, normalizedChildStart, childEnd, childPath);
scanTime = normalizedChildStart;
}
if (scanTime > start) {
count(context, nodeId, scanTime - start);
}
}
def events = [:];
def metadata = [:];
def processedEvents = [:];
def entryTransactions = [];
def timeByNodeId = [:];
def nodes = [:];
def rootNodes = [];
def operationIdByNodeId = [:];
def context = [
"events": events,
"metadata": metadata,
"processedEvents": processedEvents,
"entryTransactions": entryTransactions,
"timeByNodeId": timeByNodeId,
"nodes": nodes,
"operationIdByNodeId": operationIdByNodeId,
"params": params
];
for(state in states) {
if (state.eventsById != null) {
events.putAll(state.eventsById);
}
if (state.metadataByOperationId != null) {
metadata.putAll(state.metadataByOperationId);
}
}
for(def event: events.values()) {
processEvent(context, event);
}
for(transaction in context.entryTransactions) {
transaction.skew = 0;
transaction.offset = 0;
setOffsetAndSkew(context, transaction, null, transaction.timestamp);
def path = [];
def parent = transaction;
while (parent != null) {
path.add(parent.operationId);
if (parent.parentId == null) {
break;
}
parent = context.processedEvents[parent.parentId];
}
Collections.reverse(path);
def nodeId = toHash(path);
scan(context, transaction, 0, transaction.duration, path);
if (!rootNodes.contains(nodeId)) {
rootNodes.add(nodeId);
}
}
return [
"timeByNodeId": timeByNodeId,
"metadata": metadata,
"nodes": nodes,
"rootNodes": rootNodes,
"operationIdByNodeId": operationIdByNodeId
];`,
},
},
},
},
},
});
logger.debug(
`Retrieved critical path in ${Date.now() - now}ms, took: ${response.took}ms`
);
if (!response.aggregations) {
return {
criticalPath: null,
};
}
const criticalPath = response.aggregations?.critical_path
.value as CriticalPathResponse;
return {
criticalPath,
};
}

View file

@ -6,6 +6,7 @@
*/
import * as t from 'io-ts';
import { nonEmptyStringRt } from '@kbn/io-ts-utils';
import { TraceSearchType } from '../../../common/trace_explorer';
import { setupRequest } from '../../lib/helpers/setup_request';
import { getSearchTransactionsEvents } from '../../lib/helpers/transactions';
@ -23,6 +24,10 @@ import { getTraceItems } from './get_trace_items';
import { getTraceSamplesByQuery } from './get_trace_samples_by_query';
import { getRandomSampler } from '../../lib/helpers/get_random_sampler';
import { getApmEventClient } from '../../lib/helpers/get_apm_event_client';
import {
CriticalPathResponse,
getAggregatedCriticalPath,
} from './get_aggregated_critical_path';
const tracesRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/traces',
@ -194,10 +199,49 @@ const findTracesRoute = createApmServerRoute({
},
});
const aggregatedCriticalPathRoute = createApmServerRoute({
endpoint: 'POST /internal/apm/traces/aggregated_critical_path',
params: t.type({
body: t.intersection([
t.type({
traceIds: t.array(t.string),
serviceName: t.union([nonEmptyStringRt, t.null]),
transactionName: t.union([nonEmptyStringRt, t.null]),
}),
rangeRt,
]),
}),
options: {
tags: ['access:apm'],
},
handler: async (
resources
): Promise<{ criticalPath: CriticalPathResponse | null }> => {
const {
params: {
body: { traceIds, start, end, serviceName, transactionName },
},
} = resources;
const apmEventClient = await getApmEventClient(resources);
return getAggregatedCriticalPath({
traceIds,
start,
end,
apmEventClient,
serviceName,
transactionName,
logger: resources.logger,
});
},
});
export const traceRouteRepository = {
...tracesByIdRoute,
...tracesRoute,
...rootTransactionByTraceIdRoute,
...transactionByIdRoute,
...findTracesRoute,
...aggregatedCriticalPathRoute,
};

View file

@ -7,6 +7,7 @@
import { PartialTheme } from '@elastic/charts';
import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme';
import { useMemo } from 'react';
import { useTheme } from './use_theme';
export function useChartTheme(): PartialTheme[] {
@ -15,24 +16,27 @@ export function useChartTheme(): PartialTheme[] {
? EUI_CHARTS_THEME_DARK.theme
: EUI_CHARTS_THEME_LIGHT.theme;
return [
{
chartMargins: {
left: 10,
right: 10,
top: 10,
bottom: 10,
return useMemo(
() => [
{
chartMargins: {
left: 10,
right: 10,
top: 10,
bottom: 10,
},
background: {
color: 'transparent',
},
lineSeriesStyle: {
point: { visible: false },
},
areaSeriesStyle: {
point: { visible: false },
},
},
background: {
color: 'transparent',
},
lineSeriesStyle: {
point: { visible: false },
},
areaSeriesStyle: {
point: { visible: false },
},
},
baseChartTheme,
];
baseChartTheme,
],
[baseChartTheme]
);
}

View file

@ -0,0 +1,426 @@
/*
* 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 { getAggregatedCriticalPathRootNodes } from '@kbn/apm-plugin/common';
import { apm, EntityArrayIterable, EntityIterable, timerange } from '@kbn/apm-synthtrace';
import expect from '@kbn/expect';
import { Assign } from '@kbn/utility-types';
import { invert, sortBy, uniq } from 'lodash';
import { SupertestReturnType } from '../../common/apm_api_supertest';
import { FtrProviderContext } from '../../common/ftr_provider_context';
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const synthtraceEsClient = getService('synthtraceEsClient');
const start = new Date('2022-01-01T00:00:00.000Z').getTime();
const end = new Date('2022-01-01T00:15:00.000Z').getTime() - 1;
type Node = ReturnType<typeof getAggregatedCriticalPathRootNodes>['rootNodes'][0];
type Metadata = NonNullable<
SupertestReturnType<'POST /internal/apm/traces/aggregated_critical_path'>['body']['criticalPath']
>['metadata'][string];
type HydratedNode = Assign<Node, { metadata?: Metadata; children: HydratedNode[] }>;
interface FormattedNode {
name: string;
value: number;
children: FormattedNode[];
}
// format tree in somewhat concise format for easier testing
function formatTree(nodes: HydratedNode[]): FormattedNode[] {
return sortBy(
nodes.map((node) => {
const name =
node.metadata?.['processor.event'] === 'transaction'
? node.metadata['transaction.name']
: node.metadata?.['span.name'] || 'root';
return { name, value: node.countExclusive, children: formatTree(node.children) };
}),
(node) => node.name
);
}
async function fetchAndBuildCriticalPathTree(
options: { fn: () => EntityIterable } & ({ serviceName: string; transactionName: string } | {})
) {
const { fn } = options;
const generator = fn();
const events = generator.toArray();
const traceIds = uniq(events.map((event) => event['trace.id']!));
await synthtraceEsClient.index(new EntityArrayIterable(events));
return apmApiClient
.readUser({
endpoint: 'POST /internal/apm/traces/aggregated_critical_path',
params: {
body: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
traceIds,
serviceName: 'serviceName' in options ? options.serviceName : null,
transactionName: 'transactionName' in options ? options.transactionName : null,
},
},
})
.then((response) => {
const criticalPath = response.body.criticalPath!;
const nodeIdByOperationId = invert(criticalPath.operationIdByNodeId);
const { rootNodes, maxDepth } = getAggregatedCriticalPathRootNodes({
criticalPath,
});
function hydrateNode(node: Node): HydratedNode {
return {
...node,
metadata: criticalPath.metadata[criticalPath.operationIdByNodeId[node.nodeId]],
children: node.children.map(hydrateNode),
};
}
return {
rootNodes: rootNodes.map(hydrateNode),
maxDepth,
criticalPath,
nodeIdByOperationId,
};
});
}
registry.when('Aggregated critical path', { config: 'basic', archives: [] }, () => {
it('builds up the correct tree for a single transaction', async () => {
const java = apm
.service({ name: 'java', environment: 'production', agentName: 'java' })
.instance('java');
const duration = 1000;
const rate = 10;
const { rootNodes } = await fetchAndBuildCriticalPathTree({
fn: () =>
timerange(start, end)
.interval('15m')
.rate(rate)
.generator((timestamp) => {
return java.transaction('GET /api').timestamp(timestamp).duration(duration);
}),
});
expect(rootNodes.length).to.be(1);
expect(rootNodes[0].countInclusive).to.eql(duration * rate * 1000);
expect(rootNodes[0].countExclusive).to.eql(duration * rate * 1000);
expect(rootNodes[0].metadata).to.eql({
'processor.event': 'transaction',
'transaction.type': 'request',
'service.name': 'java',
'agent.name': 'java',
'transaction.name': 'GET /api',
});
});
it('builds up the correct tree for a complicated trace', async () => {
const java = apm
.service({ name: 'java', environment: 'production', agentName: 'java' })
.instance('java');
const rate = 10;
const { rootNodes } = await fetchAndBuildCriticalPathTree({
fn: () =>
timerange(start, end)
.interval('15m')
.rate(rate)
.generator((timestamp) => {
return java
.transaction('GET /api')
.timestamp(timestamp)
.duration(1000)
.children(
java
.span('GET /_search', 'db', 'elasticsearch')
.timestamp(timestamp)
.duration(400),
java
.span('get index stats', 'custom')
.timestamp(timestamp)
.duration(500)
.children(
java
.span('GET /*/_stats', 'db', 'elasticsearch')
.timestamp(timestamp + 50)
.duration(450)
)
);
}),
});
expect(rootNodes.length).to.be(1);
expect(rootNodes[0].countInclusive).to.eql(1000 * rate * 1000);
expect(rootNodes[0].children.length).to.eql(1);
expect(formatTree(rootNodes)).to.eql([
{
name: 'GET /api',
value: 500 * 1000 * rate,
children: [
{
name: 'get index stats',
value: 50 * 1000 * rate,
children: [{ name: 'GET /*/_stats', value: 450 * 1000 * rate, children: [] }],
},
],
},
]);
});
it('slices traces and merges root nodes if service name and transaction name are set', async () => {
// this test also fails when hashCode() is used in the scripted metric aggregation,
// due to collisions.
const upstreamA = apm
.service({ name: 'upstreamA', environment: 'production', agentName: 'java' })
.instance('upstreamA');
const upstreamB = apm
.service({ name: 'upstreamB', environment: 'production', agentName: 'java' })
.instance('upstreamB');
const downstream = apm
.service({ name: 'downstream', environment: 'production', agentName: 'java' })
.instance('downstream');
const rate = 10;
function generateTrace() {
return timerange(start, end)
.interval('15m')
.rate(rate)
.generator((timestamp) => {
return [
upstreamA
.transaction('GET /upstreamA')
.timestamp(timestamp)
.duration(500)
.children(
upstreamA
.span('GET /downstream', 'external', 'http')
.timestamp(timestamp)
.duration(500)
.children(
downstream
.transaction('downstream')
.timestamp(timestamp + 50)
.duration(400)
.children(
downstream
.span('from upstreamA', 'custom')
.timestamp(timestamp + 100)
.duration(300)
)
)
),
upstreamB
.transaction('GET /upstreamB')
.timestamp(timestamp)
.duration(500)
.children(
upstreamB
.span('GET /downstream', 'external', 'http')
.timestamp(timestamp)
.duration(500)
.children(
downstream
.transaction('downstream')
.timestamp(timestamp + 50)
.duration(400)
.children(
downstream
.span('from upstreamB', 'custom')
.timestamp(timestamp + 100)
.duration(300)
)
)
),
];
});
}
const { rootNodes: unfilteredRootNodes } = await fetchAndBuildCriticalPathTree({
fn: () => generateTrace(),
});
await synthtraceEsClient.clean();
const { rootNodes: filteredRootNodes } = await fetchAndBuildCriticalPathTree({
fn: () => generateTrace(),
serviceName: 'downstream',
transactionName: 'downstream',
});
expect(formatTree(unfilteredRootNodes)).eql([
{
name: 'GET /upstreamA',
value: 0,
children: [
{
name: 'GET /downstream',
value: 100 * 1000 * rate,
children: [
{
name: 'downstream',
value: 100 * 1000 * rate,
children: [
{
name: 'from upstreamA',
value: 300 * 1000 * rate,
children: [],
},
],
},
],
},
],
},
{
name: 'GET /upstreamB',
value: 0,
children: [
{
name: 'GET /downstream',
value: 100 * 1000 * rate,
children: [
{
name: 'downstream',
value: 100 * 1000 * rate,
children: [
{
name: 'from upstreamB',
value: 300 * 1000 * rate,
children: [],
},
],
},
],
},
],
},
]);
expect(formatTree(filteredRootNodes)).eql([
{
name: 'downstream',
value: 2 * 100 * 1000 * rate,
children: [
{
name: 'from upstreamA',
value: 300 * 1000 * rate,
children: [],
},
{
name: 'from upstreamB',
value: 300 * 1000 * rate,
children: [],
},
],
},
]);
});
it('calculates the critical path for a specific transaction if its not part of the critical path of the entire trace', async () => {
const upstream = apm
.service({ name: 'upstream', environment: 'production', agentName: 'java' })
.instance('upstream');
const downstreamA = apm
.service({ name: 'downstreamA', environment: 'production', agentName: 'java' })
.instance('downstreamB');
const downstreamB = apm
.service({ name: 'downstreamB', environment: 'production', agentName: 'java' })
.instance('downstreamB');
const rate = 10;
function generateTrace() {
return timerange(start, end)
.interval('15m')
.rate(rate)
.generator((timestamp) => {
return [
upstream
.transaction('GET /upstream')
.timestamp(timestamp)
.duration(500)
.children(
upstream
.span('GET /downstreamA', 'external', 'http')
.timestamp(timestamp)
.duration(500)
.children(
downstreamA
.transaction('downstreamA')
.timestamp(timestamp + 50)
.duration(400)
),
upstream
.span('GET /downstreamB', 'external', 'http')
.timestamp(timestamp)
.duration(400)
.children(
downstreamB
.transaction('downstreamB')
.timestamp(timestamp + 50)
.duration(400)
)
),
];
});
}
const { rootNodes: unfilteredRootNodes } = await fetchAndBuildCriticalPathTree({
fn: () => generateTrace(),
});
expect(formatTree(unfilteredRootNodes)[0].children[0].children).to.eql([
{
name: 'downstreamA',
value: 400 * rate * 1000,
children: [],
},
]);
await synthtraceEsClient.clean();
const { rootNodes: filteredRootNodes } = await fetchAndBuildCriticalPathTree({
fn: () => generateTrace(),
serviceName: 'downstreamB',
transactionName: 'downstreamB',
});
expect(formatTree(filteredRootNodes)).to.eql([
{
name: 'downstreamB',
value: 400 * rate * 1000,
children: [],
},
]);
});
after(() => synthtraceEsClient.clean());
});
}

View file

@ -1,9 +1,3 @@
/*
* 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.
*/
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License