[APM] Mobile most used pie charts with Lens (#144232)

* WIP

* Fix eslint

* split code

* Add unit tests

* respect search bar filter

* Fix i18n id

* Add open in Lens action

* Clean up attributes

* Organise react components

* Move most used chart to service overview

* Remove kuery from filters

* Pass mobile filters to Lens

* Use same ES fields as mobile filters

* Fix tests

* Clean up code

* Fix i18n

* Use i18n for lens labels

* Address PR comments
This commit is contained in:
Katerina Patticha 2022-11-02 16:11:03 +01:00 committed by GitHub
parent 404d08f600
commit 14e4ab4367
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 532 additions and 11 deletions

View file

@ -45,7 +45,7 @@ exports[`Error CONTAINER_IMAGE 1`] = `undefined`;
exports[`Error DESTINATION_ADDRESS 1`] = `undefined`;
exports[`Error DEVICE_MODEL_IDENTIFIER 1`] = `undefined`;
exports[`Error DEVICE_MODEL_NAME 1`] = `undefined`;
exports[`Error ERROR_CULPRIT 1`] = `"handleOopsie"`;
@ -310,7 +310,7 @@ exports[`Span CONTAINER_IMAGE 1`] = `undefined`;
exports[`Span DESTINATION_ADDRESS 1`] = `undefined`;
exports[`Span DEVICE_MODEL_IDENTIFIER 1`] = `undefined`;
exports[`Span DEVICE_MODEL_NAME 1`] = `undefined`;
exports[`Span ERROR_CULPRIT 1`] = `undefined`;
@ -571,7 +571,7 @@ exports[`Transaction CONTAINER_IMAGE 1`] = `undefined`;
exports[`Transaction DESTINATION_ADDRESS 1`] = `undefined`;
exports[`Transaction DEVICE_MODEL_IDENTIFIER 1`] = `undefined`;
exports[`Transaction DEVICE_MODEL_NAME 1`] = `undefined`;
exports[`Transaction ERROR_CULPRIT 1`] = `undefined`;

View file

@ -161,5 +161,5 @@ export const TIER = '_tier';
export const INDEX = '_index';
// Mobile
export const DEVICE_MODEL_IDENTIFIER = 'device.model.identifier';
export const NETWORK_CONNECTION_TYPE = 'network.connection.type';
export const DEVICE_MODEL_NAME = 'device.model.name';

View file

@ -21,6 +21,7 @@
"unifiedSearch",
"dataViews",
"advancedSettings",
"lens",
"maps"
],
"optionalPlugins": [
@ -51,4 +52,4 @@
"esUiShared",
"maps"
]
}
}

View file

@ -53,6 +53,7 @@ export const renderApp = ({
observabilityRuleTypeRegistry,
dataViews: pluginsStart.dataViews,
unifiedSearch: pluginsStart.unifiedSearch,
lens: pluginsStart.lens,
};
// render APM feedback link in global help menu

View file

@ -0,0 +1,101 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Most used chart with Lens gets lens attributes 1`] = `
Object {
"references": Array [
Object {
"id": "apm_static_index_pattern_id",
"name": "indexpattern-datasource-layer-host-os-version",
"type": "index-pattern",
},
],
"state": Object {
"datasourceStates": Object {
"formBased": Object {
"layers": Object {
"host-os-version": Object {
"columnOrder": Array [
"termsColumn",
"countColumn",
],
"columns": Object {
"countColumn": Object {
"dataType": "number",
"isBucketed": false,
"label": "Count of records",
"operationType": "count",
"scale": "ratio",
"sourceField": "___records___",
},
"termsColumn": Object {
"dataType": "string",
"isBucketed": true,
"label": "Top 5 values of host.os.version",
"operationType": "terms",
"params": Object {
"orderBy": Object {
"columnId": "countColumn",
"type": "column",
},
"orderDirection": "desc",
"size": 5,
},
"scale": "ordinal",
"sourceField": "host.os.version",
},
},
},
},
},
},
"filters": Array [
Object {
"meta": Object {},
"query": Object {
"term": Object {
"processor.event": "transaction",
},
},
},
Object {
"meta": Object {},
"query": Object {
"term": Object {
"service.name": "opbeans-swift",
},
},
},
Object {
"meta": Object {},
"query": Object {
"term": Object {
"transaction.type": "request",
},
},
},
],
"query": Object {
"language": "kuery",
"query": "",
},
"visualization": Object {
"layers": Array [
Object {
"categoryDisplay": "default",
"layerId": "host-os-version",
"layerType": "data",
"legendDisplay": "hide",
"metric": "countColumn",
"numberDisplay": "percent",
"primaryGroups": Array [
"termsColumn",
],
},
],
"shape": "pie",
},
},
"title": "most-used-host-os-version",
"visualizationType": "lnsPie",
}
`;

View file

@ -0,0 +1,115 @@
/*
* 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 {
CountIndexPatternColumn,
TermsIndexPatternColumn,
PersistedIndexPatternLayer,
PieVisualizationState,
TypedLensByValueInput,
} from '@kbn/lens-plugin/public';
import type { Filter } from '@kbn/es-query';
import { APM_STATIC_DATA_VIEW_ID } from '../../../../../../common/data_view_constants';
import { MostUsedMetricTypes } from '.';
const BUCKET_SIZE = 5;
export function getLensAttributes({
metric,
filters,
kuery = '',
}: {
metric: MostUsedMetricTypes;
filters: Filter[];
kuery?: string;
}): TypedLensByValueInput['attributes'] {
const metricId = metric.replaceAll('.', '-');
const columnA = 'termsColumn';
const columnB = 'countColumn';
const dataLayer: PersistedIndexPatternLayer = {
columnOrder: [columnA, columnB],
columns: {
[columnA]: {
label: i18n.translate(
'xpack.apm.serviceOverview.lensFlyout.topValues',
{
defaultMessage: 'Top {BUCKET_SIZE} values of {metric}',
values: {
BUCKET_SIZE,
metric,
},
}
),
dataType: 'string',
operationType: 'terms',
scale: 'ordinal',
sourceField: metric,
isBucketed: true,
params: {
size: BUCKET_SIZE,
orderBy: {
type: 'column',
columnId: columnB,
},
orderDirection: 'desc',
},
} as TermsIndexPatternColumn,
[columnB]: {
label: i18n.translate(
'xpack.apm.serviceOverview.lensFlyout.countRecords',
{
defaultMessage: 'Count of records',
}
),
dataType: 'number',
operationType: 'count',
scale: 'ratio',
isBucketed: false,
sourceField: '___records___',
} as CountIndexPatternColumn,
},
};
return {
title: `most-used-${metricId}`,
visualizationType: 'lnsPie',
references: [
{
type: 'index-pattern',
id: APM_STATIC_DATA_VIEW_ID,
name: `indexpattern-datasource-layer-${metricId}`,
},
],
state: {
visualization: {
shape: 'pie',
layers: [
{
layerId: metricId,
primaryGroups: [columnA],
metric: columnB,
categoryDisplay: 'default',
legendDisplay: 'hide',
numberDisplay: 'percent',
layerType: 'data',
},
],
} as PieVisualizationState,
datasourceStates: {
formBased: {
layers: {
[metricId]: dataLayer,
},
},
},
filters,
query: { language: 'kuery', query: kuery },
},
};
}

View file

@ -0,0 +1,122 @@
/*
* 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 { EuiTitle, EuiFlexItem, EuiPanel } from '@elastic/eui';
import React, { useMemo, useCallback } from 'react';
import type { Filter } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { ApmPluginStartDeps } from '../../../../../plugin';
import { getLensAttributes } from './get_lens_attributes';
import {
DEVICE_MODEL_NAME,
HOST_OS_VERSION,
NETWORK_CONNECTION_TYPE,
SERVICE_VERSION,
} from '../../../../../../common/elasticsearch_fieldnames';
export type MostUsedMetricTypes =
| typeof DEVICE_MODEL_NAME
| typeof SERVICE_VERSION
| typeof HOST_OS_VERSION
| typeof NETWORK_CONNECTION_TYPE;
export function MostUsedChart({
title,
start,
end,
kuery,
filters,
metric,
}: {
title: React.ReactNode;
start: string;
end: string;
kuery?: string;
filters: Filter[];
metric: MostUsedMetricTypes;
}) {
const { services } = useKibana<ApmPluginStartDeps>();
const {
lens: { EmbeddableComponent, navigateToPrefilledEditor, canUseEditor },
} = services;
const lensAttributes = useMemo(
() =>
getLensAttributes({
kuery,
filters,
metric,
}),
[kuery, filters, metric]
);
const openInLens = useCallback(() => {
if (lensAttributes) {
navigateToPrefilledEditor(
{
id: `dataVisualizer-${metric}`,
timeRange: {
from: start,
to: end,
},
attributes: lensAttributes,
},
{
openInNewTab: true,
}
);
}
}, [navigateToPrefilledEditor, lensAttributes, start, end, metric]);
const getOpenInLensAction = () => {
return {
id: 'openInLens',
type: 'link',
getDisplayName() {
return i18n.translate('xpack.apm.serviceOverview.openInLens', {
defaultMessage: 'Open in Lens',
});
},
getIconType() {
return 'visArea';
},
async isCompatible() {
return true;
},
async execute() {
openInLens();
return;
},
};
};
return (
<EuiPanel hasBorder={true}>
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h2>{title}</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EmbeddableComponent
viewMode={ViewMode.VIEW}
id={`most-used-${metric.replaceAll('.', '-')}`}
hidePanelTitles
withDefaultActions
style={{ height: 200 }}
attributes={lensAttributes}
timeRange={{
from: start,
to: end,
}}
{...(canUseEditor() && { extraActions: [getOpenInLensAction()] })}
/>
</EuiFlexItem>
</EuiPanel>
);
}

View file

@ -0,0 +1,102 @@
/*
* 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 { MemoryRouter } from 'react-router-dom';
import { CoreStart } from '@kbn/core/public';
import React, { ReactNode } from 'react';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { MockApmPluginContextWrapper } from '../../../../../context/apm_plugin/mock_apm_plugin_context';
import { getLensAttributes } from './get_lens_attributes';
import { MostUsedChart, MostUsedMetricTypes } from '.';
import { HOST_OS_VERSION } from '../../../../../../common/elasticsearch_fieldnames';
const mockEmbeddableComponent = jest.fn();
function Wrapper({ children }: { children?: ReactNode }) {
const KibanaReactContext = createKibanaReactContext({
lens: {
EmbeddableComponent: mockEmbeddableComponent.mockReturnValue(
<div data-test-subj="lens-mock" />
),
canUseEditor: jest.fn(() => true),
navigateToPrefilledEditor: jest.fn(),
},
} as Partial<CoreStart>);
return (
<MemoryRouter>
<KibanaReactContext.Provider>
<MockApmPluginContextWrapper>{children}</MockApmPluginContextWrapper>
</KibanaReactContext.Provider>
</MemoryRouter>
);
}
const renderOptions = { wrapper: Wrapper };
describe('Most used chart with Lens', () => {
const props = {
metric: HOST_OS_VERSION as MostUsedMetricTypes,
filters: [
{
meta: {},
query: {
term: {
'processor.event': 'transaction',
},
},
},
{
meta: {},
query: {
term: {
'service.name': 'opbeans-swift',
},
},
},
{
meta: {},
query: {
term: {
'transaction.type': 'request',
},
},
},
],
};
test('gets lens attributes', () => {
expect(getLensAttributes(props)).toMatchSnapshot();
});
test('Renders most used chart with Lens', () => {
const start = '2022-10-30T20%3A52%3A47.080Z';
const end = '2022-10-31T20%3A52%3A47.080Z';
render(
<MostUsedChart
title="Most used os version"
start={start}
end={end}
metric={HOST_OS_VERSION as MostUsedMetricTypes}
filters={props.filters}
/>,
renderOptions
);
expect(mockEmbeddableComponent).toHaveBeenCalledTimes(1);
expect(mockEmbeddableComponent.mock.calls[0][0]).toEqual(
expect.objectContaining({
timeRange: {
from: start,
to: end,
},
attributes: getLensAttributes(props),
})
);
});
});

View file

@ -18,10 +18,16 @@ import { TransactionsTable } from '../../../shared/transactions_table';
import { AggregatedTransactionsBadge } from '../../../shared/aggregated_transactions_badge';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { useTimeRange } from '../../../../hooks/use_time_range';
import { MostUsedChart } from './most_used_chart';
import { LatencyMap } from './latency_map';
import { MobileFilters } from './filters';
import { useFiltersForMobileCharts } from './use_filters_for_mobile_charts';
import {
DEVICE_MODEL_NAME,
HOST_OS_VERSION,
NETWORK_CONNECTION_TYPE,
SERVICE_VERSION,
} from '../../../../../common/elasticsearch_fieldnames';
interface Props {
latencyChartHeight: number;
rowDirection: 'column' | 'row';
@ -46,10 +52,10 @@ export function ServiceOverviewMobileCharts({
kuery,
rangeFrom,
rangeTo,
netConnectionType,
device,
osVersion,
appVersion,
netConnectionType,
},
} = useApmParams('/services/{serviceName}/overview');
@ -148,11 +154,82 @@ export function ServiceOverviewMobileCharts({
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiPanel hasBorder={true}>
<LatencyMap filters={filters} />
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction={rowDirection} gutterSize="s">
{/* Device */}
<EuiFlexItem>
<MostUsedChart
title={i18n.translate(
'xpack.apm.serviceOverview.mostUsed.device',
{
defaultMessage: 'Most used device',
}
)}
metric={DEVICE_MODEL_NAME}
start={start}
end={end}
kuery={kuery}
filters={filters}
/>
</EuiFlexItem>
{/* NCT */}
<EuiFlexItem>
<MostUsedChart
title={i18n.translate('xpack.apm.serviceOverview.mostUsed.nct', {
defaultMessage: 'Most used NCT',
})}
metric={NETWORK_CONNECTION_TYPE}
start={start}
end={end}
kuery={kuery}
filters={filters}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction={rowDirection} gutterSize="s">
{/* OS Version */}
<EuiFlexItem>
<MostUsedChart
title={i18n.translate(
'xpack.apm.serviceOverview.mostUsed.osVersion',
{
defaultMessage: 'Most used OS version',
}
)}
metric={HOST_OS_VERSION}
start={start}
end={end}
kuery={kuery}
filters={filters}
/>
</EuiFlexItem>
{/* App version */}
<EuiFlexItem>
<MostUsedChart
title={i18n.translate(
'xpack.apm.serviceOverview.mostUsed.appVersion',
{
defaultMessage: 'Most used app version',
}
)}
metric={SERVICE_VERSION}
start={start}
end={end}
kuery={kuery}
filters={filters}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -16,7 +16,7 @@ import {
TRANSACTION_TYPE,
PROCESSOR_EVENT,
HOST_OS_VERSION,
DEVICE_MODEL_IDENTIFIER,
DEVICE_MODEL_NAME,
NETWORK_CONNECTION_TYPE,
SERVICE_VERSION,
} from '../../../../../common/elasticsearch_fieldnames';
@ -52,7 +52,7 @@ export function useFiltersForMobileCharts() {
...termQuery(SERVICE_NAME, serviceName),
...termQuery(TRANSACTION_TYPE, transactionType),
...termQuery(HOST_OS_VERSION, osVersion),
...termQuery(DEVICE_MODEL_IDENTIFIER, device),
...termQuery(DEVICE_MODEL_NAME, device),
...termQuery(NETWORK_CONNECTION_TYPE, netConnectionType),
...termQuery(SERVICE_VERSION, appVersion),
...environmentQuery(environment),

View file

@ -21,6 +21,7 @@ import type {
DataPublicPluginStart,
DataPublicPluginSetup,
} from '@kbn/data-plugin/public';
import { LensPublicStart } from '@kbn/lens-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { EmbeddableStart } from '@kbn/embeddable-plugin/public';
import type { HomePublicPluginSetup } from '@kbn/home-plugin/public';
@ -97,6 +98,7 @@ export interface ApmPluginStartDeps {
dataViews: DataViewsPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
storage: IStorageWrapper;
lens: LensPublicStart;
}
const servicesTitle = i18n.translate('xpack.apm.navigation.servicesTitle', {

View file

@ -12,7 +12,7 @@ import {
rangeQuery,
} from '@kbn/observability-plugin/server';
import {
DEVICE_MODEL_IDENTIFIER,
DEVICE_MODEL_NAME,
HOST_OS_VERSION,
NETWORK_CONNECTION_TYPE,
SERVICE_NAME,
@ -76,7 +76,7 @@ export async function getMobileFilters({
aggs: {
devices: {
terms: {
field: DEVICE_MODEL_IDENTIFIER,
field: DEVICE_MODEL_NAME,
size: 10,
},
},