Skip to content

Commit

Permalink
[APM] Average latency map for mobile service overview (#144127)
Browse files Browse the repository at this point in the history
* Add average latency map to mobile service overview
  • Loading branch information
gbamparop authored Nov 1, 2022
1 parent 8969009 commit 58b53d8
Show file tree
Hide file tree
Showing 9 changed files with 512 additions and 2 deletions.
6 changes: 4 additions & 2 deletions x-pack/plugins/apm/kibana.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"share",
"unifiedSearch",
"dataViews",
"advancedSettings"
"advancedSettings",
"maps"
],
"optionalPlugins": [
"actions",
Expand All @@ -47,6 +48,7 @@
"kibanaUtils",
"ml",
"observability",
"esUiShared"
"esUiShared",
"maps"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { EmbeddedMap } from './embedded_map';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
import { MockApmPluginContextWrapper } from '../../../../../context/apm_plugin/mock_apm_plugin_context';
import { MemoryRouter } from 'react-router-dom';

describe('Embedded Map', () => {
it('it renders', async () => {
const mockSetLayerList = jest.fn();
const mockUpdateInput = jest.fn();
const mockRender = jest.fn();

const mockEmbeddable = embeddablePluginMock.createStartContract();
mockEmbeddable.getEmbeddableFactory = jest.fn().mockImplementation(() => ({
create: () => ({
setLayerList: mockSetLayerList,
updateInput: mockUpdateInput,
render: mockRender,
}),
}));

const { findByTestId } = render(
<MemoryRouter
initialEntries={[
'/services/{serviceName}/overview?rangeFrom=now-15m&rangeTo=now&',
]}
>
<MockApmPluginContextWrapper>
<KibanaContextProvider services={{ embeddable: mockEmbeddable }}>
<EmbeddedMap filters={[]} />
</KibanaContextProvider>
</MockApmPluginContextWrapper>
</MemoryRouter>
);
expect(
await findByTestId('serviceOverviewEmbeddedMap')
).toBeInTheDocument();

expect(mockSetLayerList).toHaveBeenCalledTimes(1);
expect(mockUpdateInput).toHaveBeenCalledTimes(1);
expect(mockRender).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React, { useEffect, useState, useRef } from 'react';
import uuid from 'uuid';
import {
MapEmbeddable,
MapEmbeddableInput,
MapEmbeddableOutput,
} from '@kbn/maps-plugin/public';
import { MAP_SAVED_OBJECT_TYPE } from '@kbn/maps-plugin/common';
import {
ErrorEmbeddable,
ViewMode,
isErrorEmbeddable,
} from '@kbn/embeddable-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { EuiText } from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import { ApmPluginStartDeps } from '../../../../../plugin';
import { getLayerList } from './get_layer_list';
import { useApmParams } from '../../../../../hooks/use_apm_params';
import { useTimeRange } from '../../../../../hooks/use_time_range';

function EmbeddedMapComponent({ filters }: { filters: Filter[] }) {
const {
query: { rangeFrom, rangeTo, kuery },
} = useApmParams('/services/{serviceName}/overview');

const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const [error, setError] = useState<boolean>();

const [embeddable, setEmbeddable] = useState<
MapEmbeddable | ErrorEmbeddable | undefined
>();

const embeddableRoot: React.RefObject<HTMLDivElement> =
useRef<HTMLDivElement>(null);

const {
embeddable: embeddablePlugin,
maps,
notifications,
} = useKibana<ApmPluginStartDeps>().services;

useEffect(() => {
async function setupEmbeddable() {
const factory = embeddablePlugin?.getEmbeddableFactory<
MapEmbeddableInput,
MapEmbeddableOutput,
MapEmbeddable
>(MAP_SAVED_OBJECT_TYPE);

if (!factory) {
setError(true);
notifications?.toasts.addDanger({
title: i18n.translate(
'xpack.apm.serviceOverview.embeddedMap.error.toastTitle',
{
defaultMessage: 'An error occurred when adding map embeddable',
}
),
text: i18n.translate(
'xpack.apm.serviceOverview.embeddedMap.error.toastDescription',
{
defaultMessage: `Embeddable factory with id "{embeddableFactoryId}" was not found.`,
values: {
embeddableFactoryId: MAP_SAVED_OBJECT_TYPE,
},
}
),
});
return;
}

const input: MapEmbeddableInput = {
attributes: { title: '' },
id: uuid.v4(),
title: i18n.translate(
'xpack.apm.serviceOverview.embeddedMap.input.title',
{
defaultMessage: 'Latency by country',
}
),
filters,
viewMode: ViewMode.VIEW,
isLayerTOCOpen: false,
query: {
query: kuery,
language: 'kuery',
},
timeRange: {
from: start,
to: end,
},
hideFilterActions: true,
};

const embeddableObject = await factory.create(input);
if (embeddableObject && !isErrorEmbeddable(embeddableObject)) {
const layerList = await getLayerList(maps);
await embeddableObject.setLayerList(layerList);
}

setEmbeddable(embeddableObject);
}

setupEmbeddable();
// Set up exactly once after the component mounts
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// We can only render after embeddable has already initialized
useEffect(() => {
if (embeddableRoot.current && embeddable) {
embeddable.render(embeddableRoot.current);
}
}, [embeddable, embeddableRoot]);

useEffect(() => {
if (embeddable) {
embeddable.updateInput({
filters,
query: {
query: kuery,
language: 'kuery',
},
timeRange: {
from: start,
to: end,
},
});
}
}, [start, end, kuery, filters, embeddable]);

return (
<>
{error && (
<EuiText size="s">
<p>
{i18n.translate('xpack.apm.serviceOverview.embeddedMap.error', {
defaultMessage: 'Could not load map',
})}
</p>
</EuiText>
)}
{!error && (
<div
data-test-subj="serviceOverviewEmbeddedMap"
css={css`
width: 100%;
height: 400px;
display: flex;
flex: 1 1 100%;
z-index: 1;
min-height: 0;
`}
ref={embeddableRoot}
/>
)}
</>
);
}

EmbeddedMapComponent.displayName = 'EmbeddedMap';

export const EmbeddedMap = React.memo(EmbeddedMapComponent);
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*
* 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 {
EMSFileSourceDescriptor,
LayerDescriptor as BaseLayerDescriptor,
VectorLayerDescriptor as BaseVectorLayerDescriptor,
VectorStyleDescriptor,
AGG_TYPE,
COLOR_MAP_TYPE,
FIELD_ORIGIN,
LABEL_BORDER_SIZES,
LAYER_TYPE,
SOURCE_TYPES,
STYLE_TYPE,
SYMBOLIZE_AS_TYPES,
} from '@kbn/maps-plugin/common';
import uuid from 'uuid';
import type { MapsStartApi } from '@kbn/maps-plugin/public';
import { i18n } from '@kbn/i18n';
import {
CLIENT_GEO_COUNTRY_ISO_CODE,
TRANSACTION_DURATION,
} from '../../../../../../common/elasticsearch_fieldnames';
import { APM_STATIC_DATA_VIEW_ID } from '../../../../../../common/data_view_constants';

interface VectorLayerDescriptor extends BaseVectorLayerDescriptor {
sourceDescriptor: EMSFileSourceDescriptor;
}

const FIELD_NAME = 'apm-service-overview-layer-country';
const COUNTRY_NAME = 'name';
const TRANSACTION_DURATION_COUNTRY = `__kbnjoin__avg_of_transaction.duration.us__${FIELD_NAME}`;

function getLayerStyle(): VectorStyleDescriptor {
return {
type: 'VECTOR',
properties: {
icon: { type: STYLE_TYPE.STATIC, options: { value: 'marker' } },
fillColor: {
type: STYLE_TYPE.DYNAMIC,
options: {
color: 'Blue to Red',
colorCategory: 'palette_0',
fieldMetaOptions: { isEnabled: true, sigma: 3 },
type: COLOR_MAP_TYPE.ORDINAL,
field: {
name: TRANSACTION_DURATION_COUNTRY,
origin: FIELD_ORIGIN.JOIN,
},
useCustomColorRamp: false,
},
},
lineColor: {
type: STYLE_TYPE.DYNAMIC,
options: { color: '#3d3d3d', fieldMetaOptions: { isEnabled: true } },
},
lineWidth: { type: STYLE_TYPE.STATIC, options: { size: 1 } },
iconSize: { type: STYLE_TYPE.STATIC, options: { size: 6 } },
iconOrientation: {
type: STYLE_TYPE.STATIC,
options: { orientation: 0 },
},
labelText: {
type: STYLE_TYPE.DYNAMIC,
options: {
field: {
name: TRANSACTION_DURATION_COUNTRY,
origin: FIELD_ORIGIN.JOIN,
},
},
},
labelZoomRange: {
options: {
useLayerZoomRange: true,
minZoom: 0,
maxZoom: 24,
},
},
labelColor: {
type: STYLE_TYPE.STATIC,
options: { color: '#000000' },
},
labelSize: { type: STYLE_TYPE.STATIC, options: { size: 14 } },
labelBorderColor: {
type: STYLE_TYPE.STATIC,
options: { color: '#FFFFFF' },
},
symbolizeAs: { options: { value: SYMBOLIZE_AS_TYPES.CIRCLE } },
labelBorderSize: { options: { size: LABEL_BORDER_SIZES.SMALL } },
},
isTimeAware: true,
};
}

export async function getLayerList(maps?: MapsStartApi) {
const basemapLayerDescriptor = maps
? await maps.createLayerDescriptors.createBasemapLayerDescriptor()
: null;

const pageLoadDurationByCountryLayer: VectorLayerDescriptor = {
joins: [
{
leftField: 'iso2',
right: {
type: SOURCE_TYPES.ES_TERM_SOURCE,
id: FIELD_NAME,
term: CLIENT_GEO_COUNTRY_ISO_CODE,
metrics: [
{
type: AGG_TYPE.AVG,
field: TRANSACTION_DURATION,
label: i18n.translate(
'xpack.apm.serviceOverview.embeddedMap.metric.label',
{
defaultMessage: 'Page load duration',
}
),
},
],
indexPatternId: APM_STATIC_DATA_VIEW_ID,
applyGlobalQuery: true,
applyGlobalTime: true,
applyForceRefresh: true,
},
},
],
sourceDescriptor: {
type: SOURCE_TYPES.EMS_FILE,
id: 'world_countries',
tooltipProperties: [COUNTRY_NAME],
},
style: getLayerStyle(),
id: uuid.v4(),
label: null,
minZoom: 0,
maxZoom: 24,
alpha: 0.75,
visible: true,
type: LAYER_TYPE.GEOJSON_VECTOR,
};

return [
...(basemapLayerDescriptor ? [basemapLayerDescriptor] : []),
pageLoadDurationByCountryLayer,
] as BaseLayerDescriptor[];
}
Loading

0 comments on commit 58b53d8

Please sign in to comment.