[APM]Embeddable Trace Waterfall Enhancements (#217679)

For the embeddable waterfall to be successful, we want to remove
unnecessary information and be able to select which records should be
displayed.

We need to remove:
- Accordions
- Services Legend

We want to display (or hide anything that isn't):
- root,
- direct parent,
- current span or transaction (highlighted)
- up to 2 children.
- Errors will be represented with an icon in the embeddable form of the
waterfall and the badge in the regular form


https://github.com/user-attachments/assets/bf8d34d7-173c-4a1a-8ccf-2f98f43fc625

## Using the embeddable:

1: Loads standard trace waterfall (like the one on APM UI)
```
<ReactEmbeddableRenderer
    type="APM_TRACE_WATERFALL_EMBEDDABLE"
    getParentApi={() => ({
      getSerializedStateForChild: () => ({
        rawState: {
          serviceName: 'foo',
          traceId: 'e7b9d541fae0e25106291f7ac0947acd',
          entryTransactionId: '2d94d9d4fda31c18',
          rangeFrom: '2025-03-26T00:00:00.513Z',
          rangeTo: '2025-03-26T20:52:42.513Z',
          displayLimit: 5, //optional param when omitted it renders the entire waterfall
        },
      }),
    })}
    hidePanelChrome={true}
  />
```

2: Loads focused trace waterfall (some trace events are hidden and a
summary is available)
```
<ReactEmbeddableRenderer
    type="APM_TRACE_WATERFALL_EMBEDDABLE"
    getParentApi={() => ({
      getSerializedStateForChild: () => ({
        rawState: {
          traceId: 'e7b9d541fae0e25106291f7ac0947acd',
          rangeFrom: '2025-03-26T00:00:00.513Z',
          rangeTo: '2025-03-26T20:52:42.513Z',
          docId: SPAN_OR_TRANSACTION_ID
        },
      }),
    })}
    hidePanelChrome={true}
  />
```
This commit is contained in:
Cauê Marcondes 2025-04-17 12:10:31 -03:00 committed by GitHub
parent e5851e44e6
commit 822aef361c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1342 additions and 82 deletions

View file

@ -151,8 +151,13 @@ const VirtualRow = React.memo(
const WaterfallNode = React.memo((props: WaterfallNodeProps) => {
const { euiTheme } = useEuiTheme();
const { duration, waterfallItemId, onClickWaterfallItem, timelineMargins, node } = props;
const { criticalPathSegmentsById, getErrorCount, updateTreeNode, showCriticalPath } =
useWaterfallContext();
const {
criticalPathSegmentsById,
getErrorCount,
updateTreeNode,
showCriticalPath,
isEmbeddable,
} = useWaterfallContext();
const displayedColor = showCriticalPath ? transparentize(0.5, node.item.color) : node.item.color;
const marginLeftLevel = 8 * node.level;
@ -210,6 +215,7 @@ const WaterfallNode = React.memo((props: WaterfallNodeProps) => {
marginLeftLevel={marginLeftLevel}
onClick={onWaterfallItemClick}
segments={segments}
isEmbeddable={isEmbeddable}
/>
</EuiFlexItem>
</EuiFlexGroup>
@ -274,7 +280,7 @@ function ToggleAccordionButton({
<EuiIcon type={isOpen ? 'arrowDown' : 'arrowRight'} />
</div>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ position: 'relative' }}>
<EuiFlexItem grow={false} css={{ position: 'relative' }}>
<div
style={{
position: 'absolute',

View file

@ -26,7 +26,7 @@ import {
convertTreeToList,
updateTraceTreeNode,
reparentOrphanItems,
getLegendsAndColorBy,
generateLegendsAndAssignColorsToWaterfall,
WaterfallLegendType,
} from './waterfall_helpers';
import type { APMError } from '../../../../../../../../typings/es_schemas/ui/apm_error';
@ -1147,7 +1147,7 @@ describe('waterfall_helpers', () => {
});
});
describe('getLegendsAndColorBy', () => {
describe('generateLegendsAndAssignColorsToWaterfall', () => {
const createWaterfallItem = (overrides: Partial<IWaterfallItem>): IWaterfallItem =>
({
docType: 'span',
@ -1160,7 +1160,7 @@ describe('waterfall_helpers', () => {
...overrides,
} as IWaterfallItem);
describe('getLegendsAndColorBy', () => {
describe('generateLegendsAndAssignColorsToWaterfall', () => {
it('should generate legends for multiple services', () => {
const waterfallItems: IWaterfallItem[] = [
createWaterfallItem({
@ -1177,7 +1177,7 @@ describe('waterfall_helpers', () => {
} as Partial<IWaterfallSpan>),
];
const { legends, colorBy } = getLegendsAndColorBy(waterfallItems);
const { legends, colorBy } = generateLegendsAndAssignColorsToWaterfall(waterfallItems);
expect(legends.length).toBeGreaterThanOrEqual(2);
expect(colorBy).toBe(WaterfallLegendType.ServiceName);
});
@ -1192,7 +1192,7 @@ describe('waterfall_helpers', () => {
} as Partial<IWaterfallSpan>),
];
const { legends, colorBy } = getLegendsAndColorBy(waterfallItems);
const { legends, colorBy } = generateLegendsAndAssignColorsToWaterfall(waterfallItems);
expect(legends.length).toBeGreaterThanOrEqual(2);
expect(colorBy).toBe(WaterfallLegendType.SpanType);
});
@ -1207,7 +1207,7 @@ describe('waterfall_helpers', () => {
} as Partial<IWaterfallSpan>),
];
getLegendsAndColorBy(waterfallItems);
generateLegendsAndAssignColorsToWaterfall(waterfallItems);
expect(waterfallItems[0].color).toBeDefined();
expect(waterfallItems[1].color).toBeDefined();
@ -1222,13 +1222,13 @@ describe('waterfall_helpers', () => {
} as Partial<IWaterfallSpan>),
];
getLegendsAndColorBy(waterfallItems);
generateLegendsAndAssignColorsToWaterfall(waterfallItems);
expect(waterfallItems[0].color).toBeDefined();
});
it('should handle empty input without errors', () => {
expect(() => getLegendsAndColorBy([])).not.toThrow();
expect(() => generateLegendsAndAssignColorsToWaterfall([])).not.toThrow();
});
});
});

View file

@ -127,7 +127,7 @@ function getLegendValues(transactionOrSpan: WaterfallTransaction | WaterfallSpan
};
}
function getTransactionItem(
export function getTransactionItem(
transaction: WaterfallTransaction,
linkedChildrenCount: number = 0
): IWaterfallTransaction {
@ -148,7 +148,7 @@ function getTransactionItem(
};
}
function getSpanItem(span: WaterfallSpan, linkedChildrenCount: number = 0): IWaterfallSpan {
export function getSpanItem(span: WaterfallSpan, linkedChildrenCount: number = 0): IWaterfallSpan {
return {
docType: 'span',
doc: span,
@ -265,7 +265,7 @@ function getRootWaterfallTransaction(
}
}
export function getLegendsAndColorBy(waterfallItems: IWaterfallItem[]) {
export function generateLegendsAndAssignColorsToWaterfall(waterfallItems: IWaterfallItem[]) {
const onlyBaseSpanItems = waterfallItems.filter(
(item) => item.docType === 'span' || item.docType === 'transaction'
) as IWaterfallSpanOrTransaction[];
@ -544,7 +544,7 @@ export function getWaterfall(apiResponse: TraceAPIResponse): IWaterfall {
const rootWaterfallTransaction = getRootWaterfallTransaction(childrenByParentId);
const duration = getWaterfallDuration(items);
const { legends, colorBy } = getLegendsAndColorBy(items);
const { legends, colorBy } = generateLegendsAndAssignColorsToWaterfall(items);
return {
entryWaterfallTransaction,

View file

@ -6,25 +6,24 @@
*/
import { EuiBadge, EuiIcon, EuiText, EuiTitle, EuiToolTip, useEuiTheme } from '@elastic/eui';
import styled from '@emotion/styled';
import { i18n } from '@kbn/i18n';
import type { ReactNode } from 'react';
import React, { useRef, useEffect, useState } from 'react';
import styled from '@emotion/styled';
import React, { useEffect, useRef, useState } from 'react';
import { isMobileAgentName, isRumAgentName } from '../../../../../../../common/agent_name';
import { SPAN_ID, TRACE_ID, TRANSACTION_ID } from '../../../../../../../common/es_fields/apm';
import { asDuration } from '../../../../../../../common/utils/formatters';
import { useAnyOfApmParams } from '../../../../../../hooks/use_apm_params';
import { useApmRouter } from '../../../../../../hooks/use_apm_router';
import type { Margins } from '../../../../../shared/charts/timeline';
import { TruncateWithTooltip } from '../../../../../shared/truncate_with_tooltip';
import { SyncBadge } from './badge/sync_badge';
import { SpanLinksBadge } from './badge/span_links_badge';
import { ColdStartBadge } from './badge/cold_start_badge';
import type { IWaterfallSpanOrTransaction } from './waterfall_helpers/waterfall_helpers';
import { SpanLinksBadge } from './badge/span_links_badge';
import { SyncBadge } from './badge/sync_badge';
import { FailureBadge } from './failure_badge';
import { useApmRouter } from '../../../../../../hooks/use_apm_router';
import { useAnyOfApmParams } from '../../../../../../hooks/use_apm_params';
import { OrphanItemTooltipIcon } from './orphan_item_tooltip_icon';
import { SpanMissingDestinationTooltip } from './span_missing_destination_tooltip';
import { useWaterfallContext } from './context/use_waterfall';
import type { IWaterfallSpanOrTransaction } from './waterfall_helpers/waterfall_helpers';
type ItemType = 'transaction' | 'span' | 'error';
@ -33,6 +32,7 @@ interface IContainerStyleProps {
timelineMargins: Margins;
isSelected: boolean;
hasToggle: boolean;
hasOnClick: boolean;
}
interface IBarStyleProps {
@ -44,6 +44,7 @@ const Container = styled.div<IContainerStyleProps>`
position: relative;
display: block;
user-select: none;
min-height: 44px;
padding-top: ${({ theme }) => theme.euiTheme.size.s};
padding-bottom: ${({ theme }) => theme.euiTheme.size.m};
margin-right: ${(props) => props.timelineMargins.right}px;
@ -53,7 +54,7 @@ const Container = styled.div<IContainerStyleProps>`
: props.timelineMargins.left}px;
background-color: ${({ isSelected, theme }) =>
isSelected ? theme.euiTheme.colors.lightestShade : 'initial'};
cursor: pointer;
cursor: ${(props) => (props.hasOnClick ? 'pointer' : 'default')}};
&:hover {
background-color: ${({ theme }) => theme.euiTheme.colors.lightestShade};
@ -124,6 +125,7 @@ interface IWaterfallItemProps {
color: string;
}>;
onClick?: (flyoutDetailTab: string) => unknown;
isEmbeddable?: boolean;
}
function PrefixIcon({ item }: { item: IWaterfallSpanOrTransaction }) {
@ -202,7 +204,7 @@ function NameLabel({ item }: { item: IWaterfallSpanOrTransaction }) {
name = `${item.doc.span.composite.count}${compositePrefix} ${name}`;
}
return (
<EuiText style={{ overflow: 'hidden' }} size="s">
<EuiText css={{ overflow: 'hidden' }} size="s">
<TruncateWithTooltip content={name} text={name} />
</EuiText>
);
@ -226,9 +228,9 @@ export function WaterfallItem({
marginLeftLevel,
onClick,
segments,
isEmbeddable = false,
}: IWaterfallItemProps) {
const [widthFactor, setWidthFactor] = useState(1);
const { isEmbeddable } = useWaterfallContext();
const waterfallItemRef: React.RefObject<any> = useRef(null);
useEffect(() => {
if (waterfallItemRef?.current && marginLeftLevel) {
@ -264,6 +266,7 @@ export function WaterfallItem({
onClick(waterfallItemFlyoutTab);
}
}}
hasOnClick={onClick !== undefined}
>
<ItemBar // using inline styles instead of props to avoid generating a css class for each item
style={itemBarStyle}
@ -296,7 +299,7 @@ export function WaterfallItem({
<Duration item={item} />
{isEmbeddable ? (
<FailureBadge outcome={item.doc.event?.outcome} />
<EmbeddableErrorIcon errorCount={errorCount} />
) : (
<RelatedErrors item={item} errorCount={errorCount} />
)}
@ -316,6 +319,14 @@ export function WaterfallItem({
);
}
function EmbeddableErrorIcon({ errorCount }: { errorCount: number }) {
const theme = useEuiTheme();
if (errorCount <= 0) {
return null;
}
return <EuiIcon type="errorFilled" color={theme.euiTheme.colors.danger} size="s" />;
}
function RelatedErrors({
item,
errorCount,

View file

@ -27,9 +27,16 @@ export interface TimelineProps {
xMin?: number;
xMax?: number;
margins: Margins;
numberOfTicks?: number;
}
export function TimelineAxisContainer({ xMax, xMin, margins, marks }: TimelineProps) {
export function TimelineAxisContainer({
xMax,
xMin,
margins,
marks,
numberOfTicks,
}: TimelineProps) {
const [width, setWidth] = useState(0);
if (xMax === undefined) {
return null;
@ -38,7 +45,7 @@ export function TimelineAxisContainer({ xMax, xMin, margins, marks }: TimelinePr
return (
<EuiResizeObserver onResize={(size) => setWidth(size.width)}>
{(resizeRef) => {
const plotValues = getPlotValues({ width, xMin, xMax, margins });
const plotValues = getPlotValues({ width, xMin, xMax, margins, numberOfTicks });
const topTraceDuration = xMax - (xMin ?? 0);
return (
<div style={{ width: '100%', height: '100%' }} ref={resizeRef}>

View file

@ -15,11 +15,13 @@ export function getPlotValues({
xMin = 0,
xMax,
margins,
numberOfTicks = 7,
}: {
width: number;
xMin?: number;
xMax: number;
margins: Margins;
numberOfTicks?: number;
}) {
const xScale = scaleLinear()
.domain([xMin, xMax])
@ -27,7 +29,7 @@ export function getPlotValues({
return {
margins,
tickValues: xScale.ticks(7),
tickValues: xScale.ticks(numberOfTicks),
width,
xDomain: xScale.domain(),
xMax,

View file

@ -0,0 +1,244 @@
/*
* 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 { Meta, StoryFn } from '@storybook/react';
import React from 'react';
import { FocusedTraceWaterfall } from '.';
import type { APIReturnType } from '../../../services/rest/create_call_apm_api';
import { MockApmPluginStorybook } from '../../../context/apm_plugin/mock_apm_plugin_storybook';
type TraceItems = APIReturnType<'GET /internal/apm/traces/{traceId}/{docId}'>;
const stories: Meta<any> = {
title: 'app/TransactionDetails/focusedTraceWaterfall',
component: FocusedTraceWaterfall,
decorators: [
(StoryComponent) => (
<MockApmPluginStorybook routePath="/services/{serviceName}/transactions/view?rangeFrom=now-15m&rangeTo=now&transactionName=testTransactionName">
<StoryComponent />
</MockApmPluginStorybook>
),
],
};
export default stories;
export const Example: StoryFn<any> = () => {
return <FocusedTraceWaterfall items={data} />;
};
const data: TraceItems = {
traceItems: {
rootTransaction: {
service: {
environment: 'dev',
name: 'products-server-classic-apm',
},
transaction: {
duration: {
us: 7087,
},
result: 'HTTP 2xx',
type: 'request',
id: '3383bc5055a6ae91',
name: 'POST /products/:id/buy',
},
span: {
id: '3383bc5055a6ae91',
links: [],
},
timestamp: {
us: 1743790903871005,
},
trace: {
id: '9733815865d7f9aedcacd0893fbaa1fb',
},
processor: {
event: 'transaction',
},
agent: {
name: 'nodejs',
},
event: {
outcome: 'success',
},
},
parentDoc: {
service: {
environment: 'dev',
name: 'products-server-classic-apm',
},
span: {
action: 'POST',
name: 'POST localhost:8080',
id: 'b1d5ad707d1a2d36',
subtype: 'http',
sync: false,
type: 'external',
duration: {
us: 5891,
},
destination: {
service: {
resource: 'localhost:8080',
},
},
links: [],
},
transaction: {
id: '3383bc5055a6ae91',
},
timestamp: {
us: 1743790903871622,
},
trace: {
id: '9733815865d7f9aedcacd0893fbaa1fb',
},
processor: {
event: 'span',
},
agent: {
name: 'nodejs',
},
parent: {
id: '3383bc5055a6ae91',
},
event: {
outcome: 'success',
},
},
focusedTraceDoc: {
service: {
environment: 'dev',
name: 'payments-server-classic-apm',
},
transaction: {
duration: {
us: 4298,
},
result: 'HTTP 2xx',
type: 'request',
id: '86682e13644e14b9',
name: 'PaymentsController#processPayment',
},
span: {
id: '86682e13644e14b9',
links: [],
},
timestamp: {
us: 1743790903873047,
},
trace: {
id: '9733815865d7f9aedcacd0893fbaa1fb',
},
processor: {
event: 'transaction',
},
agent: {
name: 'java',
},
parent: {
id: 'b1d5ad707d1a2d36',
},
event: {
outcome: 'success',
},
},
focusedTraceTree: [
{
traceDoc: {
service: {
environment: 'dev',
name: 'payments-server-classic-apm',
},
span: {
name: 'POST localhost',
id: 'ffe79ff737e435cf',
subtype: 'http',
type: 'external',
duration: {
us: 1870,
},
destination: {
service: {
resource: 'localhost:4001',
},
},
links: [],
},
transaction: {
id: '86682e13644e14b9',
},
timestamp: {
us: 1743790903874690,
},
trace: {
id: '9733815865d7f9aedcacd0893fbaa1fb',
},
processor: {
event: 'span',
},
agent: {
name: 'java',
},
parent: {
id: '86682e13644e14b9',
},
event: {
outcome: 'success',
},
},
children: [
{
traceDoc: {
service: {
environment: 'dev',
name: 'dispatch-server-classic-apm',
},
transaction: {
duration: {
us: 22,
},
result: 'HTTP 2xx',
type: 'request',
id: 'bb0397cdea74db7c',
name: 'POST /dispatch/{id}',
},
span: {
id: 'bb0397cdea74db7c',
links: [],
},
timestamp: {
us: 1743790903876709,
},
trace: {
id: '9733815865d7f9aedcacd0893fbaa1fb',
},
processor: {
event: 'transaction',
},
agent: {
name: 'go',
},
parent: {
id: 'ffe79ff737e435cf',
},
event: {
outcome: 'success',
},
},
children: [],
},
],
},
],
},
summary: {
services: 1,
traceEvents: 1,
errors: 0,
},
};

View file

@ -0,0 +1,179 @@
/*
* 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 { EuiSpacer, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import React, { useMemo } from 'react';
import type { WaterfallSpan, WaterfallTransaction } from '../../../../common/waterfall/typings';
import type { APIReturnType } from '../../../services/rest/create_call_apm_api';
import type {
IWaterfallSpan,
IWaterfallTransaction,
} from '../../app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers';
import {
generateLegendsAndAssignColorsToWaterfall,
getSpanItem,
getTransactionItem,
} from '../../app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers';
import { WaterfallItem } from '../../app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item';
import { TimelineAxisContainer, VerticalLinesContainer } from '../charts/timeline';
import { TraceSummary } from './trace_summary';
type FocusedTrace = APIReturnType<'GET /internal/apm/traces/{traceId}/{docId}'>;
interface Props {
items: FocusedTrace;
isEmbeddable?: boolean;
}
const margin = {
top: 40,
left: 20,
right: 50,
bottom: 0,
};
function convertChildrenToWatefallItem(
children: NonNullable<FocusedTrace['traceItems']>['focusedTraceTree'],
rootWaterfallTransaction: IWaterfallTransaction
) {
function convert(
child: NonNullable<FocusedTrace['traceItems']>['focusedTraceTree'][0]
): Array<IWaterfallTransaction | IWaterfallSpan> {
const waterfallItem =
child.traceDoc.processor.event === 'transaction'
? getTransactionItem(child.traceDoc as WaterfallTransaction, 0)
: getSpanItem(child.traceDoc as WaterfallSpan, 0);
waterfallItem.offset = calculateOffset({ item: waterfallItem, rootWaterfallTransaction });
const convertedChildren = child.children?.length ? child.children.flatMap(convert) : [];
return [waterfallItem, ...convertedChildren];
}
return children.flatMap(convert);
}
const calculateOffset = ({
item,
rootWaterfallTransaction,
}: {
item: IWaterfallTransaction | IWaterfallSpan;
rootWaterfallTransaction: IWaterfallTransaction;
}) => item.doc.timestamp.us - rootWaterfallTransaction.doc.timestamp.us;
export function FocusedTraceWaterfall({ items, isEmbeddable = false }: Props) {
const { euiTheme } = useEuiTheme();
const traceItems = items.traceItems;
const waterfall: {
items: Array<IWaterfallTransaction | IWaterfallSpan>;
totalDuration: number;
focusedItemId?: string;
} = useMemo(() => {
const waterfallItems: Array<IWaterfallTransaction | IWaterfallSpan> = [];
if (!traceItems) {
return {
items: [],
totalDuration: 0,
focusedItemId: undefined,
};
}
const rootWaterfallTransaction = getTransactionItem(
traceItems.rootTransaction as WaterfallTransaction,
0
);
waterfallItems.push(rootWaterfallTransaction);
const parentItem = traceItems.parentDoc
? traceItems.parentDoc.processor.event === 'transaction'
? getTransactionItem(traceItems.parentDoc as WaterfallTransaction, 0)
: getSpanItem(traceItems.parentDoc as WaterfallSpan, 0)
: undefined;
if (parentItem && parentItem.id !== rootWaterfallTransaction.id) {
parentItem.offset = calculateOffset({ item: parentItem, rootWaterfallTransaction });
waterfallItems.push(parentItem);
}
const focusedItem =
traceItems.focusedTraceDoc.processor.event === 'transaction'
? getTransactionItem(traceItems.focusedTraceDoc as WaterfallTransaction, 0)
: getSpanItem(traceItems.focusedTraceDoc as WaterfallSpan, 0);
focusedItem.offset = calculateOffset({ item: focusedItem, rootWaterfallTransaction });
if (focusedItem.id !== rootWaterfallTransaction.id) {
waterfallItems.push(focusedItem);
}
const focusedItemChildren = convertChildrenToWatefallItem(
traceItems.focusedTraceTree,
rootWaterfallTransaction
);
waterfallItems.push(...focusedItemChildren);
generateLegendsAndAssignColorsToWaterfall(waterfallItems);
return {
items: waterfallItems,
totalDuration: rootWaterfallTransaction.duration,
focusedItemId: focusedItem.id,
};
}, [traceItems]);
if (!waterfall.items.length) {
return null;
}
return (
<>
<div
css={css`
position: relative;
`}
>
<div
css={css`
display: flex;
position: sticky;
top: var(--euiFixedHeadersOffset, 0);
z-index: ${euiTheme.levels.menu};
background-color: ${euiTheme.colors.emptyShade};
border-bottom: ${euiTheme.border.thin};
`}
>
<TimelineAxisContainer
xMax={waterfall.totalDuration}
margins={margin}
numberOfTicks={3}
/>
</div>
<VerticalLinesContainer xMax={waterfall.totalDuration} margins={margin} />
{waterfall.items.map((item) => (
<WaterfallItem
timelineMargins={margin}
color={item.color}
hasToggle={false}
errorCount={0}
isSelected={item.id === waterfall.focusedItemId}
item={item}
marginLeftLevel={0}
totalDuration={waterfall.totalDuration}
isEmbeddable={isEmbeddable}
/>
))}
</div>
<EuiSpacer />
<TraceSummary summary={items.summary} />
</>
);
}

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 type { PropsWithChildren } from 'react';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { TraceSummary } from './trace_summary';
import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context';
const wrapper = ({ children }: PropsWithChildren<unknown>) => (
<MockApmPluginContextWrapper>{children}</MockApmPluginContextWrapper>
);
describe('TraceSummary', () => {
it.each([
{
summary: { services: 0, traceEvents: 0, errors: 0 },
expectedText: ['0 services', '0 trace events', '0 errors'],
},
{
summary: { services: 1, traceEvents: 1, errors: 1 },
expectedText: ['1 service', '1 trace event', '1 error'],
},
{
summary: { services: 5, traceEvents: 10, errors: 3 },
expectedText: ['5 services', '10 trace events', '3 errors'],
},
])('renders correct pluralization for $summary', ({ summary, expectedText }) => {
render(<TraceSummary summary={summary} />, { wrapper });
for (const text of expectedText) {
expect(screen.getByText(text)).toBeInTheDocument();
}
});
});

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 { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, useEuiTheme } from '@elastic/eui';
import React from 'react';
import { css } from '@emotion/react';
import type { APIReturnType } from '../../../services/rest/create_call_apm_api';
type TraceSummary = APIReturnType<'GET /internal/apm/traces/{traceId}/{docId}'>['summary'];
interface Props {
summary: TraceSummary;
}
function Divider() {
const theme = useEuiTheme();
return (
<EuiFlexItem
grow={false}
css={css`
width: ${theme.euiTheme.border.width.thin};
background-color: ${theme.euiTheme.border.color};
`}
/>
);
}
export function TraceSummary({ summary }: Props) {
const theme = useEuiTheme();
return (
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiText color="subdued" size="s">
{i18n.translate('xpack.apm.traceSummary.servicesFlexItemLabel', {
defaultMessage: '{services} {services, plural, one {service} other {services}}',
values: { services: summary.services },
})}
</EuiText>
</EuiFlexItem>
<Divider />
<EuiFlexItem grow={false}>
<EuiText color="subdued" size="s">
{i18n.translate('xpack.apm.traceSummary.traceEventsFlexItemLabel', {
defaultMessage:
'{traceEvents} {traceEvents, plural, one {trace event} other {trace events}}',
values: { traceEvents: summary.traceEvents },
})}
</EuiText>
</EuiFlexItem>
<Divider />
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type="errorFilled" color={theme.euiTheme.colors.danger} size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText color="subdued" size="s">
{i18n.translate('xpack.apm.traceSummary.errorsFlexItemLabel', {
defaultMessage: '{errors} {errors, plural, one {error} other {errors}}',
values: { errors: summary.errors },
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,48 @@
/*
* 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 { EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { FocusedTraceWaterfall } from '../../components/shared/focused_trace_waterfall';
import { isPending, useFetcher } from '../../hooks/use_fetcher';
import { Loading } from './loading';
import type { ApmTraceWaterfallEmbeddableFocusedProps } from './react_embeddable_factory';
export function FocusedTraceWaterfallEmbeddable({
rangeFrom,
rangeTo,
traceId,
docId,
}: ApmTraceWaterfallEmbeddableFocusedProps) {
const { data, status } = useFetcher(
(callApmApi) => {
return callApmApi('GET /internal/apm/traces/{traceId}/{docId}', {
params: {
path: { traceId, docId },
query: { start: rangeFrom, end: rangeTo },
},
});
},
[docId, rangeFrom, rangeTo, traceId]
);
if (isPending(status)) {
return <Loading />;
}
if (data === undefined) {
return (
<EuiText>
{i18n.translate(
'xpack.apm.focusedTraceWaterfallEmbeddable.traceWaterfallCouldNotTextLabel',
{ defaultMessage: 'Trace waterfall could not be loaded' }
)}
</EuiText>
);
}
return <FocusedTraceWaterfall items={data} isEmbeddable />;
}

View file

@ -0,0 +1,28 @@
/*
* 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, EuiLoadingSpinner, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
export function Loading() {
return (
<EuiFlexGroup direction="column" alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="l" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
{i18n.translate(
'xpack.apm.traceWaterfallEmbeddable.loadingTraceWaterfallSkeletonTextLabel',
{ defaultMessage: 'Loading trace waterfall' }
)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -9,20 +9,33 @@ import type { SerializedTitles } from '@kbn/presentation-publishing';
import { initializeTitleManager, useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { isEmpty } from 'lodash';
import { ApmEmbeddableContext } from '../embeddable_context';
import type { EmbeddableDeps } from '../types';
import { APM_TRACE_WATERFALL_EMBEDDABLE } from './constant';
import { TraceWaterfallEmbeddable } from './trace_waterfall_embeddable';
import { FocusedTraceWaterfallEmbeddable } from './focused_trace_waterfall_embeddable';
export interface ApmTraceWaterfallEmbeddableProps extends SerializedTitles {
serviceName: string;
interface BaseProps {
traceId: string;
entryTransactionId: string;
rangeFrom: string;
rangeTo: string;
}
export interface ApmTraceWaterfallEmbeddableFocusedProps extends BaseProps, SerializedTitles {
docId: string;
}
export interface ApmTraceWaterfallEmbeddableEntryProps extends BaseProps, SerializedTitles {
serviceName: string;
entryTransactionId: string;
displayLimit?: number;
}
export type ApmTraceWaterfallEmbeddableProps =
| ApmTraceWaterfallEmbeddableFocusedProps
| ApmTraceWaterfallEmbeddableEntryProps;
export const getApmTraceWaterfallEmbeddableFactory = (deps: EmbeddableDeps) => {
const factory: ReactEmbeddableFactory<
ApmTraceWaterfallEmbeddableProps,
@ -35,12 +48,15 @@ export const getApmTraceWaterfallEmbeddableFactory = (deps: EmbeddableDeps) => {
},
buildEmbeddable: async (state, buildApi, uuid, parentApi) => {
const titleManager = initializeTitleManager(state);
const serviceName$ = new BehaviorSubject(state.serviceName);
const serviceName$ = new BehaviorSubject('serviceName' in state ? state.serviceName : '');
const traceId$ = new BehaviorSubject(state.traceId);
const entryTransactionId$ = new BehaviorSubject(state.entryTransactionId);
const entryTransactionId$ = new BehaviorSubject(
'entryTransactionId' in state ? state.entryTransactionId : ''
);
const rangeFrom$ = new BehaviorSubject(state.rangeFrom);
const rangeTo$ = new BehaviorSubject(state.rangeTo);
const displayLimit$ = new BehaviorSubject(state.displayLimit);
const displayLimit$ = new BehaviorSubject('displayLimit' in state ? state.displayLimit : 0);
const docId$ = new BehaviorSubject('docId' in state ? state.docId : '');
const api = buildApi(
{
@ -55,6 +71,7 @@ export const getApmTraceWaterfallEmbeddableFactory = (deps: EmbeddableDeps) => {
rangeFrom: rangeFrom$.getValue(),
rangeTo: rangeTo$.getValue(),
displayLimit: displayLimit$.getValue(),
docId: docId$.getValue(),
},
};
},
@ -67,32 +84,51 @@ export const getApmTraceWaterfallEmbeddableFactory = (deps: EmbeddableDeps) => {
rangeFrom: [rangeFrom$, (value) => rangeFrom$.next(value)],
rangeTo: [rangeTo$, (value) => rangeFrom$.next(value)],
displayLimit: [displayLimit$, (value) => displayLimit$.next(value)],
docId: [docId$, (value) => docId$.next(value)],
}
);
return {
api,
Component: () => {
const [serviceName, traceId, entryTransactionId, rangeFrom, rangeTo, displayLimit] =
useBatchedPublishingSubjects(
serviceName$,
traceId$,
entryTransactionId$,
rangeFrom$,
rangeTo$,
displayLimit$
);
const [
serviceName,
traceId,
entryTransactionId,
rangeFrom,
rangeTo,
displayLimit,
docId,
] = useBatchedPublishingSubjects(
serviceName$,
traceId$,
entryTransactionId$,
rangeFrom$,
rangeTo$,
displayLimit$,
docId$
);
const content = isEmpty(docId) ? (
<TraceWaterfallEmbeddable
serviceName={serviceName}
traceId={traceId}
entryTransactionId={entryTransactionId}
rangeFrom={rangeFrom}
rangeTo={rangeTo}
displayLimit={displayLimit}
/>
) : (
<FocusedTraceWaterfallEmbeddable
traceId={traceId}
rangeFrom={rangeFrom}
rangeTo={rangeTo}
docId={docId}
/>
);
return (
<ApmEmbeddableContext deps={deps} rangeFrom={rangeFrom} rangeTo={rangeTo}>
<TraceWaterfallEmbeddable
serviceName={serviceName}
traceId={traceId}
entryTransactionId={entryTransactionId}
rangeFrom={rangeFrom}
rangeTo={rangeTo}
displayLimit={displayLimit}
/>
{content}
</ApmEmbeddableContext>
);
},

View file

@ -4,14 +4,14 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { useWaterfallFetcher } from '../../components/app/transaction_details/use_waterfall_fetcher';
import { Waterfall } from '../../components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall';
import { isPending } from '../../hooks/use_fetcher';
import type { ApmTraceWaterfallEmbeddableProps } from './react_embeddable_factory';
import { WaterfallLegends } from '../../components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_legends';
import { isPending } from '../../hooks/use_fetcher';
import { Loading } from './loading';
import type { ApmTraceWaterfallEmbeddableEntryProps } from './react_embeddable_factory';
export function TraceWaterfallEmbeddable({
serviceName,
@ -20,7 +20,7 @@ export function TraceWaterfallEmbeddable({
rangeTo,
traceId,
displayLimit,
}: ApmTraceWaterfallEmbeddableProps) {
}: ApmTraceWaterfallEmbeddableEntryProps) {
const waterfallFetchResult = useWaterfallFetcher({
traceId,
transactionId: entryTransactionId,
@ -29,21 +29,7 @@ export function TraceWaterfallEmbeddable({
});
if (isPending(waterfallFetchResult.status)) {
return (
<EuiFlexGroup direction="column" alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="l" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
{i18n.translate(
'xpack.apm.traceWaterfallEmbeddable.loadingTraceWaterfallSkeletonTextLabel',
{ defaultMessage: 'Loading trace waterfall' }
)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
return <Loading />;
}
const { legends, colorBy } = waterfallFetchResult.waterfall;

View file

@ -0,0 +1,160 @@
/*
* 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 { WaterfallSpan, WaterfallTransaction } from '../../../common/waterfall/typings';
import { buildChildrenTree, buildFocusedTraceItems } from './build_focused_trace_items';
import type { TraceItems } from './get_trace_items';
const mockTraceDoc = (id: string, parentId?: string) =>
({
span: { id },
parent: parentId ? { id: parentId } : undefined,
} as WaterfallTransaction | WaterfallSpan);
describe('buildChildrenTree', () => {
it('returns an empty array when no children are found', () => {
const initialTraceDoc = mockTraceDoc('1');
const itemsGroupedByParentId = {};
const result = buildChildrenTree({
initialTraceDoc,
itemsGroupedByParentId,
maxNumberOfChildren: 2,
});
expect(result).toEqual([]);
});
it('builds a tree with children and nested children', () => {
const initialTraceDoc = mockTraceDoc('1');
const itemsGroupedByParentId = {
'1': [mockTraceDoc('2'), mockTraceDoc('3')],
'2': [mockTraceDoc('4')],
};
const result = buildChildrenTree({
initialTraceDoc,
itemsGroupedByParentId,
maxNumberOfChildren: 5,
});
expect(result).toEqual([
{
traceDoc: mockTraceDoc('2'),
children: [
{
traceDoc: mockTraceDoc('4'),
children: [],
},
],
},
{
traceDoc: mockTraceDoc('3'),
children: [],
},
]);
});
it('respects the maxNumberOfChildren limit with direct children', () => {
const initialTraceDoc = mockTraceDoc('1');
const itemsGroupedByParentId = {
'1': [mockTraceDoc('2'), mockTraceDoc('3'), mockTraceDoc('4')],
};
const result = buildChildrenTree({
initialTraceDoc,
itemsGroupedByParentId,
maxNumberOfChildren: 2,
});
expect(result).toEqual([
{
traceDoc: mockTraceDoc('2'),
children: [],
},
{
traceDoc: mockTraceDoc('3'),
children: [],
},
]);
});
it('respects the maxNumberOfChildren limit', () => {
const initialTraceDoc = mockTraceDoc('1');
const itemsGroupedByParentId = {
'1': [mockTraceDoc('2'), mockTraceDoc('3')],
'2': [mockTraceDoc('4')],
};
const result = buildChildrenTree({
initialTraceDoc,
itemsGroupedByParentId,
maxNumberOfChildren: 2,
});
expect(result).toEqual([
{
traceDoc: mockTraceDoc('2'),
children: [
{
traceDoc: mockTraceDoc('4'),
children: [],
},
],
},
]);
});
});
describe('buildFocusedTraceItems', () => {
it('returns undefined if the focused trace document is not found', () => {
const traceItems = { traceDocs: [mockTraceDoc('1')] } as unknown as TraceItems;
const result = buildFocusedTraceItems({ traceItems, docId: 'non-existent-id' });
expect(result).toBeUndefined();
});
it('returns the correct focused trace document and its children', () => {
const traceItems = {
traceDocs: [
mockTraceDoc('1'),
mockTraceDoc('2', '1'),
mockTraceDoc('3', '1'),
mockTraceDoc('4', '2'),
],
} as unknown as TraceItems;
const result = buildFocusedTraceItems({ traceItems, docId: '1' });
expect(result).toEqual({
rootTransaction: mockTraceDoc('1'),
parentDoc: undefined,
focusedTraceDoc: mockTraceDoc('1'),
focusedTraceTree: [
{
traceDoc: mockTraceDoc('2', '1'),
children: [
{
traceDoc: mockTraceDoc('4', '2'),
children: [],
},
],
},
],
});
});
it('returns the correct parent document if it exists', () => {
const traceItems = {
traceDocs: [mockTraceDoc('1'), mockTraceDoc('2', '1'), mockTraceDoc('3', '2')],
} as unknown as TraceItems;
const result = buildFocusedTraceItems({ traceItems, docId: '3' });
expect(result).toEqual({
rootTransaction: mockTraceDoc('1'),
parentDoc: mockTraceDoc('2', '1'),
focusedTraceDoc: mockTraceDoc('3', '2'),
focusedTraceTree: [],
});
});
it('handles root transactions correctly', () => {
const traceItems = {
traceDocs: [mockTraceDoc('1'), mockTraceDoc('2', '1'), mockTraceDoc('3', '2')],
} as unknown as TraceItems;
const result = buildFocusedTraceItems({ traceItems, docId: '1' });
expect(result?.rootTransaction).toEqual(mockTraceDoc('1'));
});
});

View file

@ -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 { isEmpty } from 'lodash';
import type { TraceDoc, TraceItems } from './get_trace_items';
interface Child {
traceDoc: TraceDoc;
children?: Child[];
}
const MAX_NUMBER_OF_CHILDREN = 2;
export function buildChildrenTree({
initialTraceDoc,
itemsGroupedByParentId,
maxNumberOfChildren,
}: {
initialTraceDoc: TraceDoc;
itemsGroupedByParentId: Record<string, TraceDoc[]>;
maxNumberOfChildren: number;
}) {
let _processedItemsCount = 0;
function findChildren(traceDoc: TraceDoc) {
const id = getId(traceDoc);
if (!id) {
return [];
}
const children: Child[] = [];
const _children = itemsGroupedByParentId[id];
if (isEmpty(_children)) {
return [];
}
for (let i = 0; i < _children.length; i++) {
const child = _children[i];
_processedItemsCount++;
if (_processedItemsCount > maxNumberOfChildren) {
break;
}
children.push({ traceDoc: child, children: findChildren(child) });
}
return children;
}
return findChildren(initialTraceDoc);
}
export interface FocusedTraceItems {
rootTransaction: TraceDoc;
parentDoc?: TraceDoc;
focusedTraceDoc: TraceDoc;
focusedTraceTree: Child[];
}
export function buildFocusedTraceItems({
traceItems,
docId,
}: {
traceItems: TraceItems;
docId: string;
}): FocusedTraceItems | undefined {
const { traceDocs } = traceItems;
const itemsById = traceDocs.reduce<Record<string, TraceDoc>>((acc, curr) => {
const id = getId(curr);
if (!id) {
return acc;
}
acc[id] = curr;
return acc;
}, {});
const focusedTraceDoc = itemsById[docId];
if (!focusedTraceDoc) {
return undefined;
}
const parentDoc = focusedTraceDoc.parent?.id ? itemsById[focusedTraceDoc.parent?.id] : undefined;
const itemsGroupedByParentId = traceDocs.reduce<Record<string, TraceDoc[]>>((acc, curr) => {
const parentId = curr.parent?.id;
const isRootTransaction = !parentId;
if (isRootTransaction) {
acc.root = [curr];
return acc;
}
const group = acc[parentId] || [];
group.push(curr);
acc[parentId] = group;
return acc;
}, {});
const rootTransaction = itemsGroupedByParentId.root?.[0];
const focusedTraceTree = buildChildrenTree({
initialTraceDoc: focusedTraceDoc,
itemsGroupedByParentId,
maxNumberOfChildren: MAX_NUMBER_OF_CHILDREN,
});
return {
rootTransaction,
parentDoc,
focusedTraceDoc,
focusedTraceTree,
};
}
const getId = (traceDoc: TraceDoc) => traceDoc.span?.id ?? traceDoc.transaction?.id;

View file

@ -64,9 +64,11 @@ import { ApmDocumentType } from '../../../common/document_type';
import { RollupInterval } from '../../../common/rollup';
import { mapOtelToSpanLink } from '../span_links/utils';
export type TraceDoc = WaterfallTransaction | WaterfallSpan;
export interface TraceItems {
exceedsMax: boolean;
traceDocs: Array<WaterfallTransaction | WaterfallSpan>;
traceDocs: TraceDoc[];
errorDocs: WaterfallError[];
spanLinksCountById: Record<string, number>;
traceDocsTotal: number;

View file

@ -0,0 +1,44 @@
/*
* 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 { APMEventClient } from '@kbn/apm-data-access-plugin/server';
import { ProcessorEvent } from '@kbn/observability-plugin/common';
import { rangeQuery, termQuery } from '@kbn/observability-plugin/server';
import { SERVICE_NAME, TRACE_ID } from '../../../common/es_fields/apm';
export async function getTraceSummaryCount({
apmEventClient,
start,
end,
traceId,
}: {
apmEventClient: APMEventClient;
start: number;
end: number;
traceId: string;
}) {
const params = {
apm: {
events: [ProcessorEvent.span, ProcessorEvent.transaction],
},
track_total_hits: true,
size: 0,
query: {
bool: {
filter: [...rangeQuery(start, end), ...termQuery(TRACE_ID, traceId)],
},
},
aggs: { serviceCount: { cardinality: { field: SERVICE_NAME } } },
};
const { aggregations, hits } = await apmEventClient.search(
'observability_overview_get_service_count',
params
);
return { services: aggregations?.serviceCount.value || 0, traceEvents: hits.total.value };
}

View file

@ -5,29 +5,32 @@
* 2.0.
*/
import * as t from 'io-ts';
import { toNumberRt } from '@kbn/io-ts-utils';
import * as t from 'io-ts';
import { TraceSearchType } from '../../../common/trace_explorer';
import type { Span } from '../../../typings/es_schemas/ui/span';
import type { Transaction } from '../../../typings/es_schemas/ui/transaction';
import { getApmEventClient } from '../../lib/helpers/get_apm_event_client';
import { getRandomSampler } from '../../lib/helpers/get_random_sampler';
import { getSearchTransactionsEvents } from '../../lib/helpers/transactions';
import { createApmServerRoute } from '../apm_routes/create_apm_server_route';
import { environmentRt, kueryRt, probabilityRt, rangeRt } from '../default_api_types';
import { getSpan } from '../transactions/get_span';
import { getTransaction } from '../transactions/get_transaction';
import { getTransactionByName } from '../transactions/get_transaction_by_name';
import {
type TransactionDetailRedirectInfo,
getRootTransactionByTraceId,
type TransactionDetailRedirectInfo,
} from '../transactions/get_transaction_by_trace';
import type { FocusedTraceItems } from './build_focused_trace_items';
import { buildFocusedTraceItems } from './build_focused_trace_items';
import type { TopTracesPrimaryStatsResponse } from './get_top_traces_primary_stats';
import { getTopTracesPrimaryStats } from './get_top_traces_primary_stats';
import type { TraceItems } from './get_trace_items';
import { getTraceItems } from './get_trace_items';
import type { TraceSamplesResponse } from './get_trace_samples_by_query';
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 { getSpan } from '../transactions/get_span';
import type { Transaction } from '../../../typings/es_schemas/ui/transaction';
import type { Span } from '../../../typings/es_schemas/ui/span';
import { getTransactionByName } from '../transactions/get_transaction_by_name';
import { getTraceSummaryCount } from './get_trace_summary_count';
const tracesRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/traces',
@ -114,6 +117,49 @@ const tracesByIdRoute = createApmServerRoute({
},
});
const focusedTraceRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/traces/{traceId}/{docId}',
params: t.type({
path: t.type({
traceId: t.string,
docId: t.string,
}),
query: t.intersection([rangeRt, t.partial({ maxTraceItems: toNumberRt })]),
}),
security: { authz: { requiredPrivileges: ['apm'] } },
handler: async (
resources
): Promise<{
traceItems?: FocusedTraceItems;
summary: { services: number; traceEvents: number; errors: number };
}> => {
const apmEventClient = await getApmEventClient(resources);
const { params, config, logger } = resources;
const { traceId, docId } = params.path;
const { start, end } = params.query;
const [traceItems, traceSummaryCount] = await Promise.all([
getTraceItems({
traceId,
config,
apmEventClient,
start,
end,
maxTraceItemsFromUrlParam: params.query.maxTraceItems,
logger,
}),
getTraceSummaryCount({ apmEventClient, start, end, traceId }),
]);
const focusedTraceItems = buildFocusedTraceItems({ traceItems, docId });
return {
traceItems: focusedTraceItems,
summary: { ...traceSummaryCount, errors: traceItems.errorDocs.length },
};
},
});
const rootTransactionByTraceIdRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/traces/{traceId}/root_transaction',
params: t.type({
@ -319,4 +365,5 @@ export const traceRouteRepository = {
...transactionFromTraceByIdRoute,
...spanFromTraceByIdRoute,
...transactionByNameRoute,
...focusedTraceRoute,
};

View file

@ -0,0 +1,238 @@
/*
* 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 { apm, timerange } from '@kbn/apm-synthtrace-client';
import expect from '@kbn/expect';
import type { Environment } from '@kbn/apm-plugin/common/environment_rt';
import { ENVIRONMENT_ALL } from '@kbn/apm-plugin/common/environment_filter_values';
import { TraceSearchType } from '@kbn/apm-plugin/common/trace_explorer';
import type { APIReturnType } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
import { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace';
import type { DeploymentAgnosticFtrProviderContext } from '../../../../ftr_provider_context';
import { generateTrace } from './generate_trace';
type FocusedTraceResponseType = APIReturnType<'GET /internal/apm/traces/{traceId}/{docId}'>;
export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderContext) {
const apmApiClient = getService('apmApi');
const synthtrace = getService('synthtrace');
const start = new Date('2022-01-01T00:00:00.000Z').getTime();
const end = new Date('2022-01-01T00:15:00.000Z').getTime() - 1;
const endWithOffset = end + 100000;
describe('traces', () => {
async function fetchTraceSamples({
query,
type,
environment,
}: {
query: string;
type: TraceSearchType;
environment: Environment;
}) {
return apmApiClient.readUser({
endpoint: `GET /internal/apm/traces/find`,
params: {
query: {
query,
type,
start: new Date(start).toISOString(),
end: new Date(endWithOffset).toISOString(),
environment,
},
},
});
}
async function fetchFocusedTrace({
traceId,
docId,
}: {
traceId: string;
docId: string | undefined;
}) {
if (!docId) {
return undefined;
}
return apmApiClient.readUser({
endpoint: `GET /internal/apm/traces/{traceId}/{docId}`,
params: {
path: { traceId, docId },
query: {
start: new Date(start).toISOString(),
end: new Date(endWithOffset).toISOString(),
},
},
});
}
let apmSynthtraceEsClient: ApmSynthtraceEsClient;
before(async () => {
apmSynthtraceEsClient = await synthtrace.createApmSynthtraceEsClient();
});
after(() => apmSynthtraceEsClient.clean());
describe('when traces exist', () => {
before(() => {
const java = apm
.service({ name: 'java', environment: 'production', agentName: 'java' })
.instance('java');
const node = apm
.service({ name: 'node', environment: 'development', agentName: 'nodejs' })
.instance('node');
const python = apm
.service({ name: 'python', environment: 'production', agentName: 'python' })
.instance('python');
return apmSynthtraceEsClient.index(
timerange(start, end)
.interval('15m')
.rate(1)
.generator((timestamp) =>
generateTrace(timestamp, [python, node, java], 'elasticsearch')
)
);
});
describe('focused trace', () => {
let traceId: string;
let focusedTrace: FocusedTraceResponseType | undefined;
let rootTransactionId: string | undefined;
before(async () => {
const response = await fetchTraceSamples({
query: '',
type: TraceSearchType.kql,
environment: ENVIRONMENT_ALL.value,
});
expect(response.status).to.be(200);
traceId = response.body.traceSamples[0].traceId;
const focusedTraceResponse = await fetchFocusedTrace({
traceId,
docId: response.body.traceSamples[0].transactionId,
});
expect(focusedTraceResponse?.status).to.be(200);
focusedTrace = focusedTraceResponse?.body;
rootTransactionId = focusedTrace?.traceItems?.rootTransaction?.transaction?.id;
});
describe('focus on root transaction', () => {
it('returns same root transaction and focused item', async () => {
expect(focusedTrace?.traceItems?.rootTransaction?.transaction?.id).to.eql(
focusedTrace?.traceItems?.focusedTraceDoc?.transaction?.id
);
});
it('does not have parent item', () => {
expect(focusedTrace?.traceItems?.parentDoc).to.be(undefined);
});
it('has 2 children', () => {
expect(focusedTrace?.traceItems?.focusedTraceTree.length).to.eql(1);
expect(focusedTrace?.traceItems?.focusedTraceTree?.[0]?.children?.length).to.eql(1);
});
it('returns trace summary', () => {
expect(focusedTrace?.summary).to.eql({
services: 3,
traceEvents: 6,
errors: 0,
});
});
});
describe('focus on node transaction', () => {
let nodeParentSpanId: string | undefined;
let nodeTransactionId: string | undefined;
before(async () => {
nodeParentSpanId = focusedTrace?.traceItems?.focusedTraceTree?.[0]?.traceDoc?.span?.id;
nodeTransactionId =
focusedTrace?.traceItems?.focusedTraceTree?.[0]?.children?.[0]?.traceDoc?.transaction
?.id;
const focusedTraceResponse = await fetchFocusedTrace({
traceId,
docId: nodeTransactionId,
});
expect(focusedTraceResponse?.status).to.be(200);
focusedTrace = focusedTraceResponse?.body;
});
it('focus on node transaction', () => {
expect(focusedTrace?.traceItems?.focusedTraceDoc?.transaction?.id).to.eql(
nodeTransactionId
);
});
it('returns root transaction', async () => {
expect(focusedTrace?.traceItems?.rootTransaction?.transaction?.id).to.eql(
rootTransactionId
);
});
it('returns parent span', () => {
expect(focusedTrace?.traceItems?.parentDoc?.span?.id).to.eql(nodeParentSpanId);
});
it('has 2 children', () => {
expect(focusedTrace?.traceItems?.focusedTraceTree.length).to.eql(1);
expect(focusedTrace?.traceItems?.focusedTraceTree?.[0]?.children?.length).to.eql(1);
});
});
describe('focused item not found', () => {
before(async () => {
const focusedTraceResponse = await fetchFocusedTrace({
traceId,
docId: 'bar',
});
expect(focusedTraceResponse?.status).to.be(200);
focusedTrace = focusedTraceResponse?.body;
});
it('returns trace summary', () => {
expect(focusedTrace?.summary).to.eql({
services: 3,
traceEvents: 6,
errors: 0,
});
});
it('returns empty focused trace', () => {
expect(focusedTrace?.traceItems).to.be(undefined);
});
});
describe('trace not found', () => {
before(async () => {
const focusedTraceResponse = await fetchFocusedTrace({
traceId: 'foo',
docId: 'bar',
});
expect(focusedTraceResponse?.status).to.be(200);
focusedTrace = focusedTraceResponse?.body;
});
it('returns trace summary', () => {
expect(focusedTrace?.summary).to.eql({
services: 0,
traceEvents: 0,
errors: 0,
});
});
it('returns empty focused trace', () => {
expect(focusedTrace?.traceItems).to.be(undefined);
});
});
});
});
});
}

View file

@ -15,5 +15,6 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext)
loadTestFile(require.resolve('./top_traces.spec.ts'));
loadTestFile(require.resolve('./trace_by_id.spec.ts'));
loadTestFile(require.resolve('./transaction_details.spec.ts'));
loadTestFile(require.resolve('./focused_trace.spec.ts'));
});
}