[APM] Average page load duration by country chart (#43567) (#44439)

* [APM] Adds chart for page load averages by country in RUM page-load view

* [APM] Simplified and refined ChoroplethMap. Added legend labels.

* - Replaced Map legend slices with smooth gradient
- fixed issue with map rendering multiple times
- renamed initial props to start with 'initial'
- added some more code comments

* use correct i18n ids

* - base color progression calc directly on euiColorPrimary
- check that a layer exists before querying features

* Addressed code review feedback

* - fixes issue where min/max was not a finite value
- cleans up mouseover handler, which updates on state changes
- formats doc count for display
- style improvements

* addressed PR feedback & updated renovate.json5

* - Removed the Legend from the ChoroplethMap
- Only render tooltip when there's data

* - fix hover state not clearing properly
- add better typing around Geojson propertier for world countries

* added missing css import
This commit is contained in:
Oliver Gupte 2019-08-29 17:20:28 -07:00 committed by GitHub
parent 4403ed3de9
commit dfe7eb6b3d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 579 additions and 47 deletions

View file

@ -641,6 +641,14 @@
'@types/jsonwebtoken',
],
},
{
groupSlug: 'mapbox-gl',
groupName: 'mapbox-gl related packages',
packageNames: [
'mapbox-gl',
'@types/mapbox-gl',
],
},
{
groupSlug: 'memoize-one',
groupName: 'memoize-one related packages',

View file

@ -13,3 +13,6 @@ bluebird.Promise.setScheduler(function (fn) { global.setImmediate.call(global, f
const MutationObserver = require('mutation-observer');
Object.defineProperty(window, 'MutationObserver', { value: MutationObserver });
const URL = { createObjectURL: () => '' };
Object.defineProperty(window, 'URL', { value: URL });

View file

@ -1,5 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Error CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`;
exports[`Error CONTAINER_ID 1`] = `undefined`;
exports[`Error ERROR_CULPRIT 1`] = `"handleOopsie"`;
@ -92,6 +94,8 @@ exports[`Error URL_FULL 1`] = `undefined`;
exports[`Error USER_ID 1`] = `undefined`;
exports[`Span CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`;
exports[`Span CONTAINER_ID 1`] = `undefined`;
exports[`Span ERROR_CULPRIT 1`] = `undefined`;
@ -184,6 +188,8 @@ exports[`Span URL_FULL 1`] = `undefined`;
exports[`Span USER_ID 1`] = `undefined`;
exports[`Transaction CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`;
exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`;
exports[`Transaction ERROR_CULPRIT 1`] = `undefined`;

View file

@ -61,3 +61,5 @@ export const METRIC_JAVA_THREAD_COUNT = 'jvm.thread.count';
export const HOST_NAME = 'host.hostname';
export const CONTAINER_ID = 'container.id';
export const POD_NAME = 'kubernetes.pod.name';
export const CLIENT_GEO_COUNTRY_ISO_CODE = 'client.geo.country_iso_code';

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;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { asTime, asInteger } from '../../../../../utils/formatters';
import { fontSizes } from '../../../../../style/variables';
export const ChoroplethToolTip: React.SFC<{
name: string;
value: number;
docCount: number;
}> = ({ name, value, docCount }) => {
return (
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSizes.large }}>{name}</div>
<div>
{i18n.translate(
'xpack.apm.metrics.pageLoadCharts.RegionMapChart.ToolTip.avgPageLoadDuration',
{
defaultMessage: 'Avg. page load duration:'
}
)}
</div>
<div style={{ fontWeight: 'bold', fontSize: fontSizes.large }}>
{asTime(value)}
</div>
<div>
(
{i18n.translate(
'xpack.apm.metrics.pageLoadCharts.RegionMapChart.ToolTip.countPageLoads',
{
values: { docCount: asInteger(docCount) },
defaultMessage: '{docCount} page loads'
}
)}
)
</div>
</div>
);
};

View file

@ -0,0 +1,269 @@
/*
* 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, {
useState,
useEffect,
useRef,
useCallback,
useMemo
} from 'react';
import { Map, NavigationControl, Popup } from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { shade, tint } from 'polished';
import { ChoroplethToolTip } from './ChoroplethToolTip';
interface ChoroplethItem {
key: string;
value: number;
docCount: number;
}
interface Tooltip {
name: string;
value: number;
docCount: number;
}
interface WorldCountryFeatureProperties {
name: string;
iso2: string;
iso3: string;
}
interface Props {
items: ChoroplethItem[];
}
const CHOROPLETH_LAYER_ID = 'choropleth_layer';
const CHOROPLETH_POLYGONS_SOURCE_ID = 'choropleth_polygons';
const GEOJSON_KEY_PROPERTY = 'iso2';
const MAPBOX_STYLE =
'https://tiles.maps.elastic.co/styles/osm-bright-desaturated/style.json';
const GEOJSON_SOURCE =
'https://vector.maps.elastic.co/files/world_countries_v1.geo.json?elastic_tile_service_tos=agree&my_app_name=ems-landing&my_app_version=7.2.0';
export function getProgressionColor(scale: number) {
const baseColor = euiLightVars.euiColorPrimary;
const adjustedScale = 0.75 * scale + 0.05; // prevents pure black & white as min/max colors.
if (adjustedScale < 0.5) {
return tint(adjustedScale * 2, baseColor);
}
if (adjustedScale > 0.5) {
return shade(1 - (adjustedScale - 0.5) * 2, baseColor);
}
return baseColor;
}
const getMin = (items: ChoroplethItem[]) =>
Math.min(...items.map(item => item.value));
const getMax = (items: ChoroplethItem[]) =>
Math.max(...items.map(item => item.value));
export const ChoroplethMap: React.SFC<Props> = props => {
const { items } = props;
const containerRef = useRef<HTMLDivElement>(null);
const [map, setMap] = useState<Map | null>(null);
const popupRef = useRef<Popup | null>(null);
const popupContainerRef = useRef<HTMLDivElement>(null);
const [tooltipState, setTooltipState] = useState<Tooltip | null>(null);
const [min, max] = useMemo(() => [getMin(items), getMax(items)], [items]);
// converts an item value to a scaled value between 0 and 1
const getValueScale = useCallback(
(value: number) => (value - min) / (max - min),
[max, min]
);
const controlScrollZoomOnWheel = useCallback((event: WheelEvent) => {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
} else {
event.stopPropagation();
}
}, []);
// side effect creates a new mouseover handler referencing new component state
// and replaces the old one stored in `updateTooltipStateOnMousemoveRef`
useEffect(() => {
const updateTooltipStateOnMousemove = (event: mapboxgl.MapMouseEvent) => {
const isMapQueryable =
map &&
popupRef.current &&
items.length &&
map.getLayer(CHOROPLETH_LAYER_ID);
if (!isMapQueryable) {
return;
}
(popupRef.current as Popup).setLngLat(event.lngLat);
const hoverFeatures = (map as Map).queryRenderedFeatures(event.point, {
layers: [CHOROPLETH_LAYER_ID]
});
if (tooltipState && hoverFeatures.length === 0) {
return setTooltipState(null);
}
const featureProperties = hoverFeatures[0]
.properties as WorldCountryFeatureProperties;
if (tooltipState && tooltipState.name === featureProperties.name) {
return;
}
const item = items.find(
({ key }) =>
featureProperties && key === featureProperties[GEOJSON_KEY_PROPERTY]
);
if (item) {
return setTooltipState({
name: featureProperties.name,
value: item.value,
docCount: item.docCount
});
}
setTooltipState(null);
};
updateTooltipStateOnMousemoveRef.current = updateTooltipStateOnMousemove;
}, [map, items, tooltipState]);
const updateTooltipStateOnMousemoveRef = useRef(
(event: mapboxgl.MapMouseEvent & mapboxgl.EventData) => {}
);
// initialization side effect, only runs once
useEffect(() => {
if (containerRef.current === null) {
return;
}
// set up Map object
const mapboxMap = new Map({
attributionControl: false,
container: containerRef.current,
dragRotate: false,
touchZoomRotate: false,
zoom: 0.85,
center: { lng: 0, lat: 30 },
style: MAPBOX_STYLE
});
mapboxMap.addControl(
new NavigationControl({ showCompass: false }),
'top-left'
);
// set up Popup object
popupRef.current = new Popup({
closeButton: false,
closeOnClick: false
});
// always use the current handler which changes with component state
mapboxMap.on('mousemove', (...args) =>
updateTooltipStateOnMousemoveRef.current(...args)
);
mapboxMap.on('mouseout', () => {
setTooltipState(null);
});
// only scroll zoom when key is pressed
const canvasElement = mapboxMap.getCanvas();
canvasElement.addEventListener('wheel', controlScrollZoomOnWheel);
mapboxMap.on('load', () => {
mapboxMap.addSource(CHOROPLETH_POLYGONS_SOURCE_ID, {
type: 'geojson',
data: GEOJSON_SOURCE
});
setMap(mapboxMap);
});
// cleanup function called when component unmounts
return () => {
canvasElement.removeEventListener('wheel', controlScrollZoomOnWheel);
};
}, [controlScrollZoomOnWheel]);
// side effect replaces choropleth layer with new one on items changes
useEffect(() => {
if (!map) {
return;
}
// find first symbol layer to place new layer in correct order
const symbolLayer = (map.getStyle().layers || []).find(
({ type }) => type === 'symbol'
);
if (map.getLayer(CHOROPLETH_LAYER_ID)) {
map.removeLayer(CHOROPLETH_LAYER_ID);
}
if (items.length === 0) {
return;
}
const stops = items.map(({ key, value }) => [
key,
getProgressionColor(getValueScale(value))
]);
const fillColor: mapboxgl.FillPaint['fill-color'] = {
property: GEOJSON_KEY_PROPERTY,
stops,
type: 'categorical',
default: 'transparent'
};
map.addLayer(
{
id: CHOROPLETH_LAYER_ID,
type: 'fill',
source: CHOROPLETH_POLYGONS_SOURCE_ID,
layout: {},
paint: {
'fill-opacity': 0.75,
'fill-color': fillColor
}
},
symbolLayer ? symbolLayer.id : undefined
);
}, [map, items, getValueScale]);
// side effect to only render the Popup when hovering a region with a matching item
useEffect(() => {
if (!(popupContainerRef.current && map && popupRef.current)) {
return;
}
if (tooltipState) {
popupRef.current.setDOMContent(popupContainerRef.current).addTo(map);
if (popupContainerRef.current.parentElement) {
popupContainerRef.current.parentElement.style.pointerEvents = 'none';
}
} else {
popupRef.current.remove();
}
}, [map, tooltipState]);
// render map container and tooltip in a hidden container
return (
<div>
<div ref={containerRef} style={{ height: 256 }} />
<div style={{ display: 'none' }}>
<div ref={popupContainerRef}>
{tooltipState ? <ChoroplethToolTip {...tooltipState} /> : null}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,36 @@
/*
* 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 { EuiFlexGrid, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useAvgDurationByCountry } from '../../../../../hooks/useAvgDurationByCountry';
import { ChoroplethMap } from '../ChoroplethMap';
export const PageLoadCharts: React.SFC = () => {
const { data } = useAvgDurationByCountry();
return (
<EuiFlexGrid columns={1} gutterSize="s">
<EuiFlexItem>
<EuiPanel>
<EuiTitle size="xs">
<span>
{i18n.translate(
'xpack.apm.metrics.pageLoadCharts.avgPageLoadByCountryLabel',
{
defaultMessage:
'Avg. page load duration distribution by country'
}
)}
</span>
</EuiTitle>
<ChoroplethMap items={data} />
</EuiPanel>
</EuiFlexItem>
</EuiFlexGrid>
);
};

View file

@ -11,7 +11,8 @@ import {
EuiIconTip,
EuiPanel,
EuiText,
EuiTitle
EuiTitle,
EuiSpacer
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Location } from 'history';
@ -33,6 +34,7 @@ import { LicenseContext } from '../../../../context/LicenseContext';
import { TransactionLineChart } from './TransactionLineChart';
import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue';
import { getTimeFormatter } from '../../../../utils/formatters';
import { PageLoadCharts } from './PageLoadCharts';
interface TransactionChartProps {
hasMLJob: boolean;
@ -53,6 +55,8 @@ const ShiftedEuiText = styled(EuiText)`
top: 5px;
`;
const RUM_PAGE_LOAD_TYPE = 'page-load';
export class TransactionCharts extends Component<TransactionChartProps> {
public getMaxY = (responseTimeSeries: TimeSeries[]) => {
const coordinates = flatten(
@ -150,51 +154,59 @@ export class TransactionCharts extends Component<TransactionChartProps> {
const formatter = getTimeFormatter(maxY);
return (
<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(
idx(license, _ => _.features.ml.is_available)
)
}
</LicenseContext.Consumer>
</EuiFlexGroup>
<TransactionLineChart
series={responseTimeSeries}
tickFormatY={this.getResponseTimeTickFormatter(formatter)}
formatTooltipValue={this.getResponseTimeTooltipFormatter(
formatter
)}
/>
</React.Fragment>
</EuiPanel>
</EuiFlexItem>
<>
<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(
idx(license, _ => _.features.ml.is_available)
)
}
</LicenseContext.Consumer>
</EuiFlexGroup>
<TransactionLineChart
series={responseTimeSeries}
tickFormatY={this.getResponseTimeTickFormatter(formatter)}
formatTooltipValue={this.getResponseTimeTooltipFormatter(
formatter
)}
/>
</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>
<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>
{transactionType === RUM_PAGE_LOAD_TYPE ? (
<>
<EuiSpacer size="s" />
<PageLoadCharts />
</>
) : null}
</>
);
}
}
@ -217,7 +229,7 @@ function tpmLabel(type?: string) {
function responseTimeLabel(type?: string) {
switch (type) {
case 'page-load':
case RUM_PAGE_LOAD_TYPE:
return i18n.translate(
'xpack.apm.metrics.transactionChart.pageLoadTimesLabel',
{

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useFetcher } from './useFetcher';
import { useUrlParams } from './useUrlParams';
import { callApmApi } from '../services/rest/callApmApi';
export function useAvgDurationByCountry() {
const {
urlParams: { serviceName, start, end },
uiFilters
} = useUrlParams();
const { data = [], error, status } = useFetcher(() => {
if (serviceName && start && end) {
return callApmApi({
pathname:
'/api/apm/services/{serviceName}/transaction_groups/avg_duration_by_country',
params: {
path: { serviceName },
query: {
start,
end,
uiFilters: JSON.stringify(uiFilters)
}
}
});
}
}, [serviceName, start, end, uiFilters]);
return {
data,
status,
error
};
}

View file

@ -0,0 +1,78 @@
/*
* 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 {
CLIENT_GEO_COUNTRY_ISO_CODE,
PROCESSOR_EVENT,
SERVICE_NAME,
TRANSACTION_DURATION,
TRANSACTION_TYPE
} from '../../../../common/elasticsearch_fieldnames';
import { PromiseReturnType } from '../../../../typings/common';
import { Setup } from '../../helpers/setup_request';
import { rangeFilter } from '../../helpers/range_filter';
export type TransactionAvgDurationByCountryAPIResponse = PromiseReturnType<
typeof getTransactionAvgDurationByCountry
>;
export async function getTransactionAvgDurationByCountry({
setup,
serviceName
}: {
setup: Setup;
serviceName: string;
}) {
const { uiFiltersES, client, config, start, end } = setup;
const params = {
index: config.get<string>('apm_oss.transactionIndices'),
body: {
size: 0,
query: {
bool: {
filter: [
{ term: { [SERVICE_NAME]: serviceName } },
{ term: { [PROCESSOR_EVENT]: 'transaction' } },
{ term: { [TRANSACTION_TYPE]: 'page-load' } },
{ exists: { field: CLIENT_GEO_COUNTRY_ISO_CODE } },
{ range: rangeFilter(start, end) },
...uiFiltersES
]
}
},
aggs: {
country_code: {
terms: {
field: CLIENT_GEO_COUNTRY_ISO_CODE,
size: 500
},
aggs: {
avg_duration: {
avg: { field: TRANSACTION_DURATION }
}
}
}
}
}
};
const resp = await client.search(params);
if (!resp.aggregations) {
return [];
}
const buckets = resp.aggregations.country_code.buckets;
const avgDurationsByCountry = buckets.map(
({ key, doc_count, avg_duration: { value } }) => ({
key,
docCount: doc_count,
value: value === null ? 0 : value
})
);
return avgDurationsByCountry;
}

View file

@ -29,7 +29,8 @@ import {
transactionGroupsBreakdownRoute,
transactionGroupsChartsRoute,
transactionGroupsDistributionRoute,
transactionGroupsRoute
transactionGroupsRoute,
transactionGroupsAvgDurationByCountry
} from './transaction_groups';
import {
errorGroupsLocalFiltersRoute,
@ -65,6 +66,7 @@ const createApmApi = () => {
.add(transactionGroupsChartsRoute)
.add(transactionGroupsDistributionRoute)
.add(transactionGroupsRoute)
.add(transactionGroupsAvgDurationByCountry)
.add(errorGroupsLocalFiltersRoute)
.add(metricsLocalFiltersRoute)
.add(servicesLocalFiltersRoute)

View file

@ -12,6 +12,7 @@ import { getTransactionBreakdown } from '../lib/transactions/breakdown';
import { getTransactionGroupList } from '../lib/transaction_groups';
import { createRoute } from './create_route';
import { uiFiltersRt, rangeRt } from './default_api_types';
import { getTransactionAvgDurationByCountry } from '../lib/transactions/avg_duration_by_country';
export const transactionGroupsRoute = createRoute(() => ({
path: '/api/apm/services/{serviceName}/transaction_groups',
@ -142,3 +143,22 @@ export const transactionGroupsBreakdownRoute = createRoute(() => ({
});
}
}));
export const transactionGroupsAvgDurationByCountry = createRoute(() => ({
path: `/api/apm/services/{serviceName}/transaction_groups/avg_duration_by_country`,
params: {
path: t.type({
serviceName: t.string
}),
query: t.intersection([uiFiltersRt, rangeRt])
},
handler: async (req, { path, query }) => {
const setup = await setupRequest(req);
const { serviceName } = path;
return getTransactionAvgDurationByCountry({
serviceName,
setup
});
}
}));

View file

@ -68,6 +68,7 @@
"@types/json-stable-stringify": "^1.0.32",
"@types/jsonwebtoken": "^7.2.7",
"@types/lodash": "^3.10.1",
"@types/mapbox-gl": "^0.54.1",
"@types/memoize-one": "^4.1.0",
"@types/mime": "^2.0.1",
"@types/mkdirp": "^0.5.2",

View file

@ -3335,6 +3335,11 @@
dependencies:
"@types/node" "*"
"@types/geojson@*":
version "7946.0.7"
resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.7.tgz#c8fa532b60a0042219cdf173ca21a975ef0666ad"
integrity sha512-wE2v81i4C4Ol09RtsWFAqg3BUitWbHSpSlIo+bNdsCJijO9sjme+zm+73ZMCa/qMC8UEERxzGbvmr1cffo2SiQ==
"@types/getopts@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/getopts/-/getopts-2.0.1.tgz#b7e5478fe7571838b45aff736a59ab69b8bcda18"
@ -3631,6 +3636,13 @@
resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-1.12.0.tgz#acf14294d18e6eba427a5e5d7dfce0f5cd2a9400"
integrity sha512-+UzPmwHSEEyv7aGlNkVpuFxp/BirXgl8NnPGCtmyx2KXIzAapoW3IqSVk87/Z3PUk8vEL8Pe1HXEMJbNBOQgtg==
"@types/mapbox-gl@^0.54.1":
version "0.54.3"
resolved "https://registry.yarnpkg.com/@types/mapbox-gl/-/mapbox-gl-0.54.3.tgz#6215fbf4dbb555d2ca6ce3be0b1de045eec0f967"
integrity sha512-/G06vUcV5ucNB7G9ka6J+VbGtffyUYvfe6A3oae/+csTlHIEHcvyJop3Ic4yeMDxycsQCmBvuwz+owseMuiQ3w==
dependencies:
"@types/geojson" "*"
"@types/markdown-it@^0.0.7":
version "0.0.7"
resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-0.0.7.tgz#75070485a3d8ad11e7deb8287f4430be15bf4d39"