[APM] Transaction breakdown charts (#39531)

* [APM] Transaction breakdown graphs

Closes #36441.

* Don't use .keyword now that mapping is correct

* Use better heuristic for determining noHits; wrap App in memo to prevent unnecessary re-renders

* Remove nested panel around TransactionBreakdown

* Fix display issues in charts

* Address review feedback

* Address additional review feedback
This commit is contained in:
Dario Gieselaar 2019-07-02 17:14:17 +02:00 committed by GitHub
parent c8e4f9c0b8
commit 73bb21b771
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 19679 additions and 369 deletions

View file

@ -56,12 +56,16 @@ exports[`Error SPAN_ID 1`] = `undefined`;
exports[`Error SPAN_NAME 1`] = `undefined`;
exports[`Error SPAN_SELF_TIME_SUM 1`] = `undefined`;
exports[`Error SPAN_SUBTYPE 1`] = `undefined`;
exports[`Error SPAN_TYPE 1`] = `undefined`;
exports[`Error TRACE_ID 1`] = `"trace id"`;
exports[`Error TRANSACTION_BREAKDOWN_COUNT 1`] = `undefined`;
exports[`Error TRANSACTION_DURATION 1`] = `undefined`;
exports[`Error TRANSACTION_ID 1`] = `"transaction id"`;
@ -134,12 +138,16 @@ exports[`Span SPAN_ID 1`] = `"span id"`;
exports[`Span SPAN_NAME 1`] = `"span name"`;
exports[`Span SPAN_SELF_TIME_SUM 1`] = `undefined`;
exports[`Span SPAN_SUBTYPE 1`] = `"my subtype"`;
exports[`Span SPAN_TYPE 1`] = `"span type"`;
exports[`Span TRACE_ID 1`] = `"trace id"`;
exports[`Span TRANSACTION_BREAKDOWN_COUNT 1`] = `undefined`;
exports[`Span TRANSACTION_DURATION 1`] = `undefined`;
exports[`Span TRANSACTION_ID 1`] = `"transaction id"`;
@ -212,12 +220,16 @@ exports[`Transaction SPAN_ID 1`] = `undefined`;
exports[`Transaction SPAN_NAME 1`] = `undefined`;
exports[`Transaction SPAN_SELF_TIME_SUM 1`] = `undefined`;
exports[`Transaction SPAN_SUBTYPE 1`] = `undefined`;
exports[`Transaction SPAN_TYPE 1`] = `undefined`;
exports[`Transaction TRACE_ID 1`] = `"trace id"`;
exports[`Transaction TRANSACTION_BREAKDOWN_COUNT 1`] = `undefined`;
exports[`Transaction TRANSACTION_DURATION 1`] = `1337`;
exports[`Transaction TRANSACTION_ID 1`] = `"transaction id"`;

View file

@ -20,12 +20,14 @@ export const TRANSACTION_RESULT = 'transaction.result';
export const TRANSACTION_NAME = 'transaction.name';
export const TRANSACTION_ID = 'transaction.id';
export const TRANSACTION_SAMPLED = 'transaction.sampled';
export const TRANSACTION_BREAKDOWN_COUNT = 'transaction.breakdown.count';
export const TRACE_ID = 'trace.id';
export const SPAN_DURATION = 'span.duration.us';
export const SPAN_TYPE = 'span.type';
export const SPAN_SUBTYPE = 'span.subtype';
export const SPAN_SELF_TIME_SUM = 'span.self_time.sum.us';
export const SPAN_ACTION = 'span.action';
export const SPAN_NAME = 'span.name';
export const SPAN_ID = 'span.id';

View file

@ -18,6 +18,7 @@ import { useLocation } from '../../../hooks/useLocation';
import { useUrlParams } from '../../../hooks/useUrlParams';
import { FETCH_STATUS } from '../../../hooks/useFetcher';
import { TransactionBreakdown } from '../../shared/TransactionBreakdown';
import { ChartsTimeContextProvider } from '../../../context/ChartsTimeContext';
export function TransactionDetails() {
const location = useLocation();
@ -42,16 +43,18 @@ export function TransactionDetails() {
</EuiTitle>
</ApmHeader>
<TransactionBreakdown />
<ChartsTimeContextProvider>
<TransactionBreakdown />
<EuiSpacer size="s" />
<EuiSpacer size="s" />
<TransactionCharts
hasMLJob={false}
charts={transactionDetailsChartsData}
urlParams={urlParams}
location={location}
/>
<TransactionCharts
hasMLJob={false}
charts={transactionDetailsChartsData}
urlParams={urlParams}
location={location}
/>
</ChartsTimeContextProvider>
<EuiHorizontalRule size="full" margin="l" />

View file

@ -27,6 +27,7 @@ import { useFetcher } from '../../../hooks/useFetcher';
import { getHasMLJob } from '../../../services/rest/ml';
import { history } from '../../../utils/history';
import { useLocation } from '../../../hooks/useLocation';
import { ChartsTimeContextProvider } from '../../../context/ChartsTimeContext';
interface Props {
urlParams: IUrlParams;
@ -114,16 +115,18 @@ export function TransactionOverview({
</EuiFormRow>
) : null}
<TransactionBreakdown />
<ChartsTimeContextProvider>
<TransactionBreakdown initialIsOpen={true} />
<EuiSpacer size="s" />
<EuiSpacer size="s" />
<TransactionCharts
hasMLJob={hasMLJob}
charts={transactionOverviewCharts}
location={location}
urlParams={urlParams}
/>
<TransactionCharts
hasMLJob={hasMLJob}
charts={transactionOverviewCharts}
location={location}
urlParams={urlParams}
/>
</ChartsTimeContextProvider>
<EuiSpacer size="s" />

View file

@ -4,10 +4,62 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import React, { useMemo, useCallback } from 'react';
import numeral from '@elastic/numeral';
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
import { Coordinate } from '../../../../../typings/timeseries';
import { TransactionLineChart } from '../../charts/TransactionCharts/TransactionLineChart';
import { asPercent } from '../../../../utils/formatters';
import { unit } from '../../../../style/variables';
const TransactionBreakdownGraph: React.FC<{}> = () => {
return <div />;
interface Props {
timeseries: Array<{
name: string;
color: string;
values: Array<{ x: number; y: number | null }>;
}>;
}
const TransactionBreakdownGraph: React.FC<Props> = props => {
const { timeseries } = props;
const series: React.ComponentProps<
typeof TransactionLineChart
>['series'] = useMemo(
() => {
return timeseries.map(timeseriesConfig => {
return {
title: timeseriesConfig.name,
color: timeseriesConfig.color,
data: timeseriesConfig.values,
type: 'area',
hideLegend: true
};
}, {});
},
[timeseries]
);
const tickFormatY = useCallback((y: number | null) => {
return numeral(y || 0).format('0 %');
}, []);
const formatTooltipValue = useCallback((coordinate: Coordinate) => {
return coordinate.y !== null && coordinate.y !== undefined
? asPercent(coordinate.y, 1)
: NOT_AVAILABLE_LABEL;
}, []);
return (
<TransactionLineChart
series={series}
stacked={true}
tickFormatY={tickFormatY}
formatTooltipValue={formatTooltipValue}
yMax={1}
height={unit * 12}
/>
);
};
export { TransactionBreakdownGraph };

View file

@ -19,7 +19,6 @@ import { FORMATTERS } from '../../../../../infra/public/utils/formatters';
interface TransactionBreakdownKpi {
name: string;
percentage: number;
count: number;
color: string;
}

View file

@ -12,7 +12,6 @@ import {
EuiSpacer,
EuiPanel
} from '@elastic/eui';
import { sortBy } from 'lodash';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import { useTransactionBreakdown } from '../../../hooks/useTransactionBreakdown';
@ -38,8 +37,10 @@ const NoTransactionsTitle = styled.span`
font-weight: bold;
`;
const TransactionBreakdown: React.FC = () => {
const [showChart, setShowChart] = useState(false);
const TransactionBreakdown: React.FC<{
initialIsOpen?: boolean;
}> = ({ initialIsOpen }) => {
const [showChart, setShowChart] = useState(!!initialIsOpen);
const {
data,
@ -47,23 +48,65 @@ const TransactionBreakdown: React.FC = () => {
receivedDataDuringLifetime
} = useTransactionBreakdown();
const kpis = useMemo(
const kpis = data ? data.kpis : undefined;
const timeseriesPerSubtype = data ? data.timeseries_per_subtype : undefined;
const legends = useMemo(
() => {
return data
? sortBy(data, 'name').map((breakdown, index) => {
return {
...breakdown,
color: COLORS[index % COLORS.length]
};
})
: null;
const names = kpis ? kpis.map(kpi => kpi.name).sort() : [];
return names.map((name, index) => {
return {
name,
color: COLORS[index % COLORS.length]
};
});
},
[data]
[kpis]
);
const sortedAndColoredKpis = useMemo(
() => {
if (!kpis) {
return null;
}
return legends.map(legend => {
const { color } = legend;
const breakdown = kpis.find(
b => b.name === legend.name
) as typeof kpis[0];
return {
...breakdown,
color
};
});
},
[kpis, legends]
);
const loading = status === FETCH_STATUS.LOADING || status === undefined;
const hasHits = data && data.length > 0;
const hasHits = data && data.kpis.length > 0;
const timeseries = useMemo(
() => {
if (!timeseriesPerSubtype) {
return [];
}
return legends.map(legend => {
const series = timeseriesPerSubtype[legend.name];
return {
name: legend.name,
values: series,
color: legend.color
};
});
},
[timeseriesPerSubtype, legends]
);
return receivedDataDuringLifetime ? (
<EuiPanel>
@ -77,9 +120,11 @@ const TransactionBreakdown: React.FC = () => {
}}
/>
</EuiFlexItem>
{hasHits && kpis ? (
{hasHits && sortedAndColoredKpis ? (
<EuiFlexItem>
{kpis && <TransactionBreakdownKpiList kpis={kpis} />}
{sortedAndColoredKpis && (
<TransactionBreakdownKpiList kpis={sortedAndColoredKpis} />
)}
</EuiFlexItem>
) : (
!loading && (
@ -114,7 +159,7 @@ const TransactionBreakdown: React.FC = () => {
)}
{showChart && hasHits ? (
<EuiFlexItem>
<TransactionBreakdownGraph />
<TransactionBreakdownGraph timeseries={timeseries} />
</EuiFlexItem>
) : null}
</EuiFlexGroup>

View file

@ -19,18 +19,22 @@ function getPointByX(serie, x) {
class InteractivePlot extends PureComponent {
getMarkPoints = hoverX => {
return this.props.series
.filter(serie =>
serie.data.some(point => point.x === hoverX && point.y != null)
)
.map(serie => {
const { x, y } = getPointByX(serie, hoverX) || {};
return {
x,
y,
color: serie.color
};
});
return (
this.props.series
.filter(serie =>
serie.data.some(point => point.x === hoverX && point.y != null)
)
.map(serie => {
const { x, y } = getPointByX(serie, hoverX) || {};
return {
x,
y,
color: serie.color
};
})
// needs to be reversed, as StaticPlot.js does the same
.reverse()
);
};
getTooltipPoints = hoverX => {

View file

@ -42,6 +42,7 @@ class StaticPlot extends PureComponent {
curve={'curveMonotoneX'}
data={serie.data}
color={serie.color}
stack={serie.stack}
/>
);
case 'area':
@ -53,8 +54,9 @@ class StaticPlot extends PureComponent {
curve={'curveMonotoneX'}
data={serie.data}
color={serie.color}
stroke={serie.color}
stroke={serie.stack ? 'rgba(0,0,0,0)' : serie.color}
fill={serie.areaColor || rgba(serie.color, 0.3)}
stack={serie.stack}
/>
);
case 'areaMaxHeight':

View file

@ -33,15 +33,32 @@ export class InnerCustomPlot extends PureComponent {
getEnabledSeries = createSelector(
state => state.visibleSeries,
state => state.seriesEnabledState,
(visibleSeries, seriesEnabledState) =>
visibleSeries.filter((serie, i) => !seriesEnabledState[i])
state => state.stacked,
(visibleSeries, seriesEnabledState, stacked) =>
visibleSeries
.filter((serie, i) => !seriesEnabledState[i])
.map(serie => {
return stacked ? { ...serie, stack: true } : serie;
})
);
getOptions = createSelector(
state => state.width,
state => state.yMin,
state => state.yMax,
(width, yMin, yMax) => ({ width, yMin, yMax })
state => state.start,
state => state.end,
state => state.height,
state => state.stacked,
(width, yMin, yMax, start, end, height, stacked) => ({
width,
yMin,
yMax,
start,
end,
height,
stacked
})
);
getPlotValues = createSelector(
@ -61,6 +78,18 @@ export class InnerCustomPlot extends PureComponent {
}
);
getStackedPlotSeries = createSelector(
state => state.enabledSeries,
series => {
return series.map(serie => {
return {
...serie,
type: 'line'
};
});
}
);
clickLegend = i => {
this.setState(({ seriesEnabledState }) => {
const nextSeriesEnabledState = this.props.series.map((value, _i) => {
@ -113,7 +142,7 @@ export class InnerCustomPlot extends PureComponent {
}
render() {
const { series, truncateLegends, noHits, width } = this.props;
const { series, truncateLegends, noHits, width, stacked } = this.props;
if (isEmpty(series) || !width) {
return null;
@ -126,7 +155,8 @@ export class InnerCustomPlot extends PureComponent {
const visibleSeries = this.getVisibleSeries({ series });
const enabledSeries = this.getEnabledSeries({
visibleSeries,
seriesEnabledState: this.state.seriesEnabledState
seriesEnabledState: this.state.seriesEnabledState,
stacked
});
const options = this.getOptions(this.props);
@ -140,6 +170,16 @@ export class InnerCustomPlot extends PureComponent {
return null;
}
const staticPlot = (
<StaticPlot
noHits={noHits}
plotValues={plotValues}
series={enabledSeries}
tickFormatY={this.props.tickFormatY}
tickFormatX={this.props.tickFormatX}
/>
);
return (
<Fragment>
<div style={{ position: 'relative', height: plotValues.XY_HEIGHT }}>
@ -151,6 +191,12 @@ export class InnerCustomPlot extends PureComponent {
tickFormatX={this.props.tickFormatX}
/>
{stacked
? React.cloneElement(staticPlot, {
series: this.getStackedPlotSeries({ enabledSeries })
})
: null}
<InteractivePlot
plotValues={plotValues}
hoverX={this.props.hoverX}
@ -193,14 +239,17 @@ InnerCustomPlot.propTypes = {
series: PropTypes.array.isRequired,
tickFormatY: PropTypes.func,
truncateLegends: PropTypes.bool,
width: PropTypes.number.isRequired
width: PropTypes.number.isRequired,
stacked: PropTypes.bool,
height: PropTypes.number
};
InnerCustomPlot.defaultProps = {
formatTooltipValue: p => p.y,
tickFormatX: undefined,
tickFormatY: y => y,
truncateLegends: false
truncateLegends: false,
stacked: false
};
export default makeWidthFlexible(InnerCustomPlot);

View file

@ -46,7 +46,7 @@ function getFlattenedCoordinates(visibleSeries, enabledSeries) {
export function getPlotValues(
visibleSeries,
enabledSeries,
{ width, yMin = 0, yMax = 'max' }
{ width, yMin = 0, yMax = 'max', start, end, height, stacked }
) {
const flattenedCoordinates = getFlattenedCoordinates(
visibleSeries,
@ -56,14 +56,17 @@ export function getPlotValues(
return null;
}
const xMin = d3.min(flattenedCoordinates, d => d.x);
const xMax = d3.max(flattenedCoordinates, d => d.x);
const xMin = start ? start : d3.min(flattenedCoordinates, d => d.x);
const xMax = end ? end : d3.max(flattenedCoordinates, d => d.x);
if (yMax === 'max') {
yMax = d3.max(flattenedCoordinates, d => d.y);
}
if (yMin === 'min') {
yMin = d3.min(flattenedCoordinates, d => d.y);
}
const xScale = getXScale(xMin, xMax, width);
const yScale = getYScale(yMin, yMax);
@ -75,22 +78,26 @@ export function getPlotValues(
y: yScale,
yTickValues,
XY_MARGIN,
XY_HEIGHT,
XY_WIDTH: width
XY_HEIGHT: height || XY_HEIGHT,
XY_WIDTH: width,
...(stacked ? { stackBy: 'y' } : {})
};
}
export function SharedPlot({ plotValues, ...props }) {
const { XY_HEIGHT: height, XY_MARGIN: margin, XY_WIDTH: width } = plotValues;
return (
<div style={{ position: 'absolute', top: 0, left: 0 }}>
<XYPlot
dontCheckIfEmpty
height={XY_HEIGHT}
margin={XY_MARGIN}
height={height}
margin={margin}
xType="time"
width={plotValues.XY_WIDTH}
width={width}
xDomain={plotValues.x.domain()}
yDomain={plotValues.y.domain()}
stackBy={plotValues.stackBy}
{...props}
/>
</div>
@ -101,6 +108,7 @@ SharedPlot.propTypes = {
plotValues: PropTypes.shape({
x: PropTypes.func.isRequired,
y: PropTypes.func.isRequired,
XY_WIDTH: PropTypes.number.isRequired
XY_WIDTH: PropTypes.number.isRequired,
height: PropTypes.number
}).isRequired
};

View file

@ -2903,6 +2903,7 @@ Array [
.c4 {
color: #98a2b3;
padding-bottom: 0;
padding-right: 8px;
}
.c7 {
@ -5002,7 +5003,7 @@ Array [
>
<circle
cx={360}
cy={189.74989696}
cy={132.27231180799998}
onClick={[Function]}
onContextMenu={[Function]}
onMouseOut={[Function]}
@ -5010,9 +5011,9 @@ Array [
r={5}
style={
Object {
"fill": "#3185fc",
"fill": "#f98510",
"opacity": 1,
"stroke": "#3185fc",
"stroke": "#f98510",
"strokeWidth": 1,
}
}
@ -5036,7 +5037,7 @@ Array [
/>
<circle
cx={360}
cy={132.27231180799998}
cy={189.74989696}
onClick={[Function]}
onContextMenu={[Function]}
onMouseOut={[Function]}
@ -5044,9 +5045,9 @@ Array [
r={5}
style={
Object {
"fill": "#f98510",
"fill": "#3185fc",
"opacity": 1,
"stroke": "#f98510",
"stroke": "#3185fc",
"strokeWidth": 1,
}
}

View file

@ -61,6 +61,7 @@ const LegendContainer = styled.div`
const LegendGray = styled(Legend)`
color: ${theme.euiColorMediumShade};
padding-bottom: 0;
padding-right: ${px(units.half)};
`;
const Value = styled.div`

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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo } from 'react';
import { flatten } from 'lodash';
import d3 from 'd3';
import {
Coordinate,
RectCoordinate
} from '../../../../../../typings/timeseries';
import { useChartsTime } from '../../../../../hooks/useChartsTime';
import { getEmptySeries } from '../../CustomPlot/getEmptySeries';
// @ts-ignore
import CustomPlot from '../../CustomPlot';
import { toQuery, fromQuery } from '../../../Links/url_helpers';
import { history } from '../../../../../utils/history';
interface Props {
series: Array<{
color: string;
title: React.ReactNode;
titleShort?: React.ReactNode;
data: Array<Coordinate | RectCoordinate>;
type: string;
}>;
stacked?: boolean;
truncateLegends?: boolean;
tickFormatY: (y: number | null) => React.ReactNode;
formatTooltipValue: (c: Coordinate) => React.ReactNode;
yMax?: string | number;
height?: number;
}
const TransactionLineChart: React.FC<Props> = (props: Props) => {
const {
series,
tickFormatY,
formatTooltipValue,
stacked = false,
yMax = 'max',
height,
truncateLegends
} = props;
const flattenedCoordinates = flatten(series.map(serie => serie.data));
const start = d3.min(flattenedCoordinates, d => d.x);
const end = d3.max(flattenedCoordinates, d => d.x);
const noHits = series.every(
serie =>
serie.data.filter(value => 'y' in value && value.y !== null).length === 0
);
const { time, setTime } = useChartsTime();
const hoverXHandlers = useMemo(
() => {
return {
onHover: (hoverX: number) => {
setTime(hoverX);
},
onMouseLeave: () => {
setTime(null);
},
onSelectionEnd: (range: { start: number; end: number }) => {
setTime(null);
const currentSearch = toQuery(history.location.search);
const nextSearch = {
rangeFrom: new Date(range.start).toISOString(),
rangeTo: new Date(range.end).toISOString()
};
history.push({
...history.location,
search: fromQuery({
...currentSearch,
...nextSearch
})
});
},
hoverX: time
};
},
[time, setTime]
);
if (!start || !end) {
return null;
}
return (
<CustomPlot
noHits={noHits}
series={noHits ? getEmptySeries(start, end) : series}
start={new Date(start).getTime()}
end={new Date(end).getTime()}
{...hoverXHandlers}
tickFormatY={tickFormatY}
formatTooltipValue={formatTooltipValue}
stacked={stacked}
yMax={yMax}
height={height}
truncateLegends={truncateLegends}
/>
);
};
export { TransactionLineChart };

View file

@ -23,11 +23,8 @@ import { ITransactionChartData } from '../../../../selectors/chartSelectors';
import { IUrlParams } from '../../../../context/UrlParamsContext/types';
import { asInteger, asMillis, tpmUnit } from '../../../../utils/formatters';
import { MLJobLink } from '../../Links/MachineLearningLinks/MLJobLink';
// @ts-ignore
import CustomPlot from '../CustomPlot';
import { SyncChartGroup } from '../SyncChartGroup';
import { LicenseContext } from '../../../../context/LicenseContext';
import { getEmptySeries } from '../CustomPlot/getEmptySeries';
import { TransactionLineChart } from './TransactionLineChart';
interface TransactionChartProps {
hasMLJob: boolean;
@ -56,7 +53,7 @@ const msTimeUnitLabel = i18n.translate(
);
export class TransactionCharts extends Component<TransactionChartProps> {
public getResponseTimeTickFormatter = (t: number) => {
public getResponseTimeTickFormatter = (t: number | null) => {
return this.props.charts.noHits ? `- ${msTimeUnitLabel}` : asMillis(t);
};
@ -134,61 +131,51 @@ export class TransactionCharts extends Component<TransactionChartProps> {
public render() {
const { charts, urlParams } = this.props;
const { noHits, responseTimeSeries, tpmSeries } = charts;
const { transactionType, start, end } = urlParams;
const { responseTimeSeries, tpmSeries } = charts;
const { transactionType } = urlParams;
return (
<SyncChartGroup
render={hoverXHandlers => (
<EuiFlexGrid columns={2} gutterSize="s">
<EuiFlexItem>
<EuiPanel>
<React.Fragment>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiTitle size="xs">
<span>{responseTimeLabel(transactionType)}</span>
</EuiTitle>
</EuiFlexItem>
<LicenseContext.Consumer>
{license =>
this.renderMLHeader(license.features.ml.is_available)
}
</LicenseContext.Consumer>
</EuiFlexGroup>
<CustomPlot
noHits={noHits}
series={
noHits ? getEmptySeries(start, end) : responseTimeSeries
}
{...hoverXHandlers}
tickFormatY={this.getResponseTimeTickFormatter}
formatTooltipValue={this.getResponseTimeTooltipFormatter}
/>
</React.Fragment>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem style={{ flexShrink: 1 }}>
<EuiPanel>
<React.Fragment>
<EuiFlexGrid columns={2} gutterSize="s">
<EuiFlexItem>
<EuiPanel>
<React.Fragment>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiTitle size="xs">
<span>{tpmLabel(transactionType)}</span>
<span>{responseTimeLabel(transactionType)}</span>
</EuiTitle>
<CustomPlot
noHits={noHits}
series={noHits ? getEmptySeries(start, end) : tpmSeries}
{...hoverXHandlers}
tickFormatY={this.getTPMFormatter}
formatTooltipValue={this.getTPMTooltipFormatter}
truncateLegends
/>
</React.Fragment>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGrid>
)}
/>
</EuiFlexItem>
<LicenseContext.Consumer>
{license =>
this.renderMLHeader(license.features.ml.is_available)
}
</LicenseContext.Consumer>
</EuiFlexGroup>
<TransactionLineChart
series={responseTimeSeries}
tickFormatY={this.getResponseTimeTickFormatter}
formatTooltipValue={this.getResponseTimeTooltipFormatter}
/>
</React.Fragment>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem style={{ flexShrink: 1 }}>
<EuiPanel>
<React.Fragment>
<EuiTitle size="xs">
<span>{tpmLabel(transactionType)}</span>
</EuiTitle>
<TransactionLineChart
series={tpmSeries}
tickFormatY={this.getTPMFormatter}
formatTooltipValue={this.getTPMTooltipFormatter}
truncateLegends
/>
</React.Fragment>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGrid>
);
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo, useState } from 'react';
const ChartsTimeContext = React.createContext<{
time: number | null;
setTime: (time: number | null) => unknown;
} | null>(null);
const ChartsTimeContextProvider: React.FC = ({ children }) => {
const [time, setTime] = useState<number | null>(null);
const value = useMemo(
() => {
return {
time,
setTime
};
},
[time, setTime]
);
return <ChartsTimeContext.Provider value={value} children={children} />;
};
export { ChartsTimeContext, ChartsTimeContextProvider };

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useContext } from 'react';
import { ChartsTimeContext } from '../context/ChartsTimeContext';
export function useChartsTime() {
const context = useContext(ChartsTimeContext);
if (!context) {
throw new Error('Missing ChartsTime context provider');
}
return context;
}

View file

@ -36,7 +36,7 @@ export function useTransactionBreakdown() {
const receivedDataDuringLifetime = useRef(false);
if (data && data.length) {
if (data && data.kpis.length) {
receivedDataDuringLifetime.current = true;
}

View file

@ -29,7 +29,7 @@ const MainContainer = styled.div`
min-height: calc(100vh - ${topNavHeight});
`;
function App() {
const App = () => {
useUpdateBadgeEffect();
return (
@ -51,7 +51,7 @@ function App() {
</UrlParamsProvider>
</MatchedRouteProvider>
);
}
};
export class Plugin {
public start(core: CoreStart) {

View file

@ -12,7 +12,11 @@ import { rgba } from 'polished';
import { TimeSeriesAPIResponse } from '../../server/lib/transactions/charts';
import { ApmTimeSeriesResponse } from '../../server/lib/transactions/charts/get_timeseries_data/transform';
import { StringMap } from '../../typings/common';
import { Coordinate, RectCoordinate } from '../../typings/timeseries';
import {
Coordinate,
RectCoordinate,
TimeSeries
} from '../../typings/timeseries';
import { asDecimal, asMillis, tpmUnit } from '../utils/formatters';
import { IUrlParams } from '../context/UrlParamsContext/types';
@ -27,7 +31,7 @@ export interface ITpmBucket {
export interface ITransactionChartData {
noHits: boolean;
tpmSeries: ITpmBucket[];
responseTimeSeries: TimeSerie[];
responseTimeSeries: TimeSeries[];
}
const INITIAL_DATA = {
@ -63,18 +67,6 @@ export function getTransactionCharts(
};
}
interface TimeSerie {
title: string;
titleShort?: string;
hideLegend?: boolean;
hideTooltipValue?: boolean;
data: Array<Coordinate | RectCoordinate>;
legendValue?: string;
type: string;
color: string;
areaColor?: string;
}
export function getResponseTimeSeries({
apmTimeseries,
anomalyTimeseries
@ -82,7 +74,7 @@ export function getResponseTimeSeries({
const { overallAvgDuration } = apmTimeseries;
const { avg, p95, p99 } = apmTimeseries.responseTimes;
const series: TimeSerie[] = [
const series: TimeSeries[] = [
{
title: i18n.translate('xpack.apm.transactions.chart.averageLabel', {
defaultMessage: 'Avg.'

View file

@ -1,21 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`getTransactionBreakdown returns transaction breakdowns grouped by type and subtype 1`] = `
Array [
Object {
"count": 15,
"name": "app",
"percentage": 0.6666666666666666,
},
Object {
"count": 175,
"name": "mysql",
"percentage": 0.3333333333333333,
},
Object {
"count": 225,
"name": "elasticsearch",
"percentage": 0.16666666666666666,
},
]
`;

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const MAX_KPIS = 20;

View file

@ -5,8 +5,9 @@
*/
import { getTransactionBreakdown } from '.';
import { noDataResponse } from './mock-responses/noData';
import { dataResponse } from './mock-responses/data';
import * as constants from './constants';
import noDataResponse from './mock-responses/noData.json';
import dataResponse from './mock-responses/data.json';
describe('getTransactionBreakdown', () => {
it('returns an empty array if no data is available', async () => {
@ -26,7 +27,9 @@ describe('getTransactionBreakdown', () => {
}
});
expect(response.length).toBe(0);
expect(response.kpis.length).toBe(0);
expect(Object.keys(response.timeseries_per_subtype).length).toBe(0);
});
it('returns transaction breakdowns grouped by type and subtype', async () => {
@ -46,26 +49,78 @@ describe('getTransactionBreakdown', () => {
}
});
expect(response.length).toBe(3);
expect(response.kpis.length).toBe(4);
expect(response.map(breakdown => breakdown.name)).toEqual([
expect(response.kpis.map(kpi => kpi.name)).toEqual([
'app',
'mysql',
'elasticsearch'
'http',
'postgresql',
'dispatcher-servlet'
]);
expect(response[0]).toEqual({
count: 15,
expect(response.kpis[0]).toEqual({
name: 'app',
percentage: 2 / 3
percentage: 0.5408550899466306
});
expect(response[1]).toEqual({
count: 175,
name: 'mysql',
percentage: 1 / 3
expect(response.kpis[2]).toEqual({
name: 'postgresql',
percentage: 0.047366859295002
});
});
it('returns a timeseries grouped by type and subtype', async () => {
const clientSpy = jest.fn().mockReturnValueOnce(dataResponse);
const response = await getTransactionBreakdown({
serviceName: 'myServiceName',
setup: {
start: 0,
end: 500000,
client: { search: clientSpy } as any,
config: {
get: () => 'myIndex' as any,
has: () => true
},
uiFiltersES: []
}
});
expect(response).toMatchSnapshot();
const { timeseries_per_subtype: timeseriesPerSubtype } = response;
expect(Object.keys(timeseriesPerSubtype).length).toBe(4);
// empty buckets should result in null values for visible types
expect(timeseriesPerSubtype.app.length).toBe(276);
expect(timeseriesPerSubtype.app.length).not.toBe(257);
expect(timeseriesPerSubtype.app[0].x).toBe(1561102380000);
expect(timeseriesPerSubtype.app[0].y).toBeCloseTo(0.8689440187037277);
});
it('should not include more KPIs than MAX_KPIs', async () => {
// @ts-ignore
constants.MAX_KPIS = 2;
const clientSpy = jest.fn().mockReturnValueOnce(dataResponse);
const response = await getTransactionBreakdown({
serviceName: 'myServiceName',
setup: {
start: 0,
end: 500000,
client: { search: clientSpy } as any,
config: {
get: () => 'myIndex' as any,
has: () => true
},
uiFiltersES: []
}
});
const { timeseries_per_subtype: timeseriesPerSubtype } = response;
expect(Object.keys(timeseriesPerSubtype)).toEqual(['app', 'http']);
});
});

View file

@ -4,15 +4,30 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { flatten } from 'lodash';
import { flatten, sortByOrder } from 'lodash';
import {
SERVICE_NAME,
SPAN_SUBTYPE,
SPAN_TYPE,
SPAN_SELF_TIME_SUM,
TRANSACTION_TYPE,
TRANSACTION_NAME,
TRANSACTION_BREAKDOWN_COUNT
} from '../../../../common/elasticsearch_fieldnames';
import { PromiseReturnType } from '../../../../typings/common';
import { Setup } from '../../helpers/setup_request';
import { rangeFilter } from '../../helpers/range_filter';
import { getMetricsDateHistogramParams } from '../../helpers/metrics';
import { MAX_KPIS } from './constants';
export type TransactionBreakdownAPIResponse = PromiseReturnType<
typeof getTransactionBreakdown
>;
interface TimeseriesMap {
[key: string]: Array<{ x: number; y: number | null }>;
}
export async function getTransactionBreakdown({
setup,
serviceName,
@ -24,6 +39,47 @@ export async function getTransactionBreakdown({
}) {
const { uiFiltersES, client, config, start, end } = setup;
const subAggs = {
sum_all_self_times: {
sum: {
field: SPAN_SELF_TIME_SUM
}
},
total_transaction_breakdown_count: {
sum: {
field: TRANSACTION_BREAKDOWN_COUNT
}
},
types: {
terms: {
field: SPAN_TYPE,
size: 20,
order: {
_count: 'desc'
}
},
aggs: {
subtypes: {
terms: {
field: SPAN_SUBTYPE,
missing: '',
size: 20,
order: {
_count: 'desc'
}
},
aggs: {
total_self_time_per_subtype: {
sum: {
field: SPAN_SELF_TIME_SUM
}
}
}
}
}
}
};
const params = {
index: config.get<string>('apm_oss.metricsIndices'),
body: {
@ -33,14 +89,14 @@ export async function getTransactionBreakdown({
must: [
{
term: {
'service.name.keyword': {
[SERVICE_NAME]: {
value: serviceName
}
}
},
{
term: {
'transaction.type.keyword': {
[TRANSACTION_TYPE]: {
value: 'request'
}
}
@ -51,7 +107,7 @@ export async function getTransactionBreakdown({
? [
{
term: {
'transaction.name.keyword': {
[TRANSACTION_NAME]: {
value: transactionName
}
}
@ -62,48 +118,10 @@ export async function getTransactionBreakdown({
}
},
aggs: {
sum_all_self_times: {
sum: {
field: 'span.self_time.sum.us'
}
},
total_transaction_breakdown_count: {
sum: {
field: 'transaction.breakdown.count'
}
},
types: {
terms: {
field: 'span.type.keyword',
size: 20,
order: {
_count: 'desc'
}
},
aggs: {
subtypes: {
terms: {
field: 'span.subtype.keyword',
missing: '',
size: 20,
order: {
_count: 'desc'
}
},
aggs: {
total_self_time_per_subtype: {
sum: {
field: 'span.self_time.sum.us'
}
},
total_span_count_per_subtype: {
sum: {
field: 'span.self_time.count'
}
}
}
}
}
...subAggs,
by_date: {
date_histogram: getMetricsDateHistogramParams(start, end),
aggs: subAggs
}
}
}
@ -111,24 +129,65 @@ export async function getTransactionBreakdown({
const resp = await client.search(params);
const sumAllSelfTimes = resp.aggregations.sum_all_self_times.value || 0;
const formatBucket = (
aggs:
| typeof resp['aggregations']
| typeof resp['aggregations']['by_date']['buckets'][0]
) => {
const sumAllSelfTimes = aggs.sum_all_self_times.value || 0;
const breakdowns = flatten(
resp.aggregations.types.buckets.map(bucket => {
const type = bucket.key;
const breakdowns = flatten(
aggs.types.buckets.map(bucket => {
const type = bucket.key;
return bucket.subtypes.buckets.map(subBucket => {
return {
name: subBucket.key || type,
percentage:
(subBucket.total_self_time_per_subtype.value || 0) /
sumAllSelfTimes,
count: subBucket.total_span_count_per_subtype.value || 0
return bucket.subtypes.buckets.map(subBucket => {
return {
name: subBucket.key || type,
percentage:
(subBucket.total_self_time_per_subtype.value || 0) /
sumAllSelfTimes
};
});
})
);
return breakdowns;
};
const kpis = sortByOrder(
formatBucket(resp.aggregations),
'percentage',
'desc'
).slice(0, MAX_KPIS);
const kpiNames = kpis.map(kpi => kpi.name);
const timeseriesPerSubtype = resp.aggregations.by_date.buckets.reduce(
(prev, bucket) => {
const formattedValues = formatBucket(bucket);
const time = bucket.key;
return kpiNames.reduce((p, kpiName) => {
const value = formattedValues.find(val => val.name === kpiName) || {
name: kpiName,
percentage: null
};
});
})
// limit to 20 items because of UI constraints
).slice(0, 20);
return breakdowns;
const { name, percentage } = value;
if (!p[name]) {
p[name] = [];
}
return {
...p,
[value.name]: p[name].concat({ x: time, y: percentage })
};
}, prev);
},
{} as TimeseriesMap
);
return {
kpis,
timeseries_per_subtype: timeseriesPerSubtype
};
}

View file

@ -1,88 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const dataResponse = {
took: 8,
timed_out: false,
_shards: {
total: 85,
successful: 85,
skipped: 0,
failed: 0
},
hits: {
total: {
value: 6,
relation: 'eq'
},
max_score: null,
hits: []
},
aggregations: {
types: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'app',
doc_count: 2,
subtypes: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: '',
doc_count: 2,
total_self_time_per_subtype: {
value: 400.0
},
total_span_count_per_subtype: {
value: 15.0
}
}
]
}
},
{
key: 'db',
doc_count: 2,
subtypes: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'mysql',
doc_count: 2,
total_self_time_per_subtype: {
value: 200.0
},
total_span_count_per_subtype: {
value: 175.0
}
},
{
key: 'elasticsearch',
doc_count: 3,
total_self_time_per_subtype: {
value: 100.0
},
total_span_count_per_subtype: {
value: 225.0
}
}
]
}
}
]
},
total_transaction_breakdown_count: {
value: 15.0
},
sum_all_self_times: {
value: 600.0
}
}
};

View file

@ -0,0 +1,20 @@
{
"took": 1,
"timed_out": false,
"_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 },
"hits": {
"total": { "value": 0, "relation": "eq" },
"max_score": null,
"hits": []
},
"aggregations": {
"by_date": { "buckets": [] },
"types": {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
"buckets": []
},
"total_transaction_breakdown_count": { "value": 0 },
"sum_all_self_times": { "value": 0 }
}
}

View file

@ -1,37 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const noDataResponse = {
took: 11,
timed_out: false,
_shards: {
total: 85,
successful: 85,
skipped: 0,
failed: 0
},
hits: {
total: {
value: 0,
relation: 'eq'
},
max_score: null,
hits: []
},
aggregations: {
types: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: []
},
total_transaction_breakdown_count: {
value: 0.0
},
sum_all_self_times: {
value: 0.0
}
}
};

View file

@ -14,5 +14,17 @@ export interface RectCoordinate {
x0: number;
}
export interface TimeSeries {
title: string;
titleShort?: string;
hideLegend?: boolean;
hideTooltipValue?: boolean;
data: Array<Coordinate | RectCoordinate>;
legendValue?: string;
type: string;
color: string;
areaColor?: string;
}
export type ChartType = 'area' | 'linemark';
export type YUnit = 'percent' | 'bytes' | 'number';