[APM] Add widgets for Most HTTP requests, Errors and Sessions, at the bottom of the Map layer/component in the service overview page (#149101)

Closes https://github.com/elastic/kibana/issues/146859

#### What was done
- Improved maps UI
- Added location widgets for most by country

<img width="1777" alt="image"
src="https://user-images.githubusercontent.com/31922082/214525608-a153ad78-0c1f-42d2-a473-857c9875c4d1.png">
This commit is contained in:
Miriam 2023-02-06 13:56:02 +00:00 committed by GitHub
parent 867a55274c
commit 6f4b5fd5de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 879 additions and 138 deletions

View file

@ -15,8 +15,14 @@ exports[`Error APP_LAUNCH_TIME 1`] = `undefined`;
exports[`Error CHILD_ID 1`] = `undefined`;
exports[`Error CLIENT_GEO_CITY_NAME 1`] = `undefined`;
exports[`Error CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`;
exports[`Error CLIENT_GEO_COUNTRY_NAME 1`] = `undefined`;
exports[`Error CLIENT_GEO_REGION_NAME 1`] = `undefined`;
exports[`Error CLOUD 1`] = `
Object {
"availability_zone": "europe-west1-c",
@ -313,8 +319,14 @@ exports[`Span APP_LAUNCH_TIME 1`] = `undefined`;
exports[`Span CHILD_ID 1`] = `undefined`;
exports[`Span CLIENT_GEO_CITY_NAME 1`] = `undefined`;
exports[`Span CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`;
exports[`Span CLIENT_GEO_COUNTRY_NAME 1`] = `undefined`;
exports[`Span CLIENT_GEO_REGION_NAME 1`] = `undefined`;
exports[`Span CLOUD 1`] = `
Object {
"availability_zone": "europe-west1-c",
@ -594,8 +606,14 @@ exports[`Transaction APP_LAUNCH_TIME 1`] = `undefined`;
exports[`Transaction CHILD_ID 1`] = `undefined`;
exports[`Transaction CLIENT_GEO_CITY_NAME 1`] = `undefined`;
exports[`Transaction CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`;
exports[`Transaction CLIENT_GEO_COUNTRY_NAME 1`] = `undefined`;
exports[`Transaction CLIENT_GEO_REGION_NAME 1`] = `undefined`;
exports[`Transaction CLOUD 1`] = `
Object {
"availability_zone": "europe-west1-c",

View file

@ -150,8 +150,6 @@ export const KUBERNETES = 'kubernetes';
export const KUBERNETES_POD_NAME = 'kubernetes.pod.name';
export const KUBERNETES_POD_UID = 'kubernetes.pod.uid';
export const CLIENT_GEO_COUNTRY_ISO_CODE = 'client.geo.country_iso_code';
export const FAAS_ID = 'faas.id';
export const FAAS_NAME = 'faas.name';
export const FAAS_COLDSTART = 'faas.coldstart';
@ -171,4 +169,10 @@ export const SESSION_ID = 'session.id';
export const APP_LAUNCH_TIME = 'application.launch.time';
export const EVENT_NAME = 'event.name';
// Location
export const CLIENT_GEO_COUNTRY_ISO_CODE = 'client.geo.country_iso_code';
export const CLIENT_GEO_COUNTRY_NAME = 'client.geo.country_name';
export const CLIENT_GEO_CITY_NAME = 'client.geo.city_name';
export const CLIENT_GEO_REGION_NAME = 'client.geo.region_name';
export const CHILD_ID = 'child.id';

View file

@ -16,7 +16,6 @@ import {
EuiSpacer,
EuiTitle,
EuiCallOut,
EuiIconTip,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
@ -42,7 +41,8 @@ import { ServiceOverviewDependenciesTable } from '../../service_overview/service
import { LatencyChart } from '../../../shared/charts/latency_chart';
import { useFiltersForEmbeddableCharts } from '../../../../hooks/use_filters_for_embeddable_charts';
import { getKueryWithMobileFilters } from '../../../../../common/utils/get_kuery_with_mobile_filters';
import { MobileStats } from './stats';
import { MobileStats } from './stats/stats';
import { MobileLocationStats } from './stats/location_stats';
/**
* The height a chart should be if it's next to a table with 5 rows and a title.
* Add the height of the pagination row.
@ -149,10 +149,11 @@ export function MobileServiceOverview() {
end={end}
kuery={kueryWithMobileFilters}
/>
<EuiSpacer size="s" />
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={5}>
<EuiFlexGroup>
<EuiFlexItem grow={8}>
<EuiPanel hasBorder={true}>
<LatencyMap
start={start}
@ -163,114 +164,106 @@ export function MobileServiceOverview() {
/>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={7}>
<EuiPanel hasBorder={true}>
<EuiFlexGroup
justifyContent="spaceBetween"
alignItems="center"
>
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h2>
{i18n.translate(
'xpack.apm.serviceOverview.mostUsedTitle',
{
defaultMessage: 'Top 5 most used',
}
)}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{comparisonEnabled && (
<EuiIconTip
content={i18n.translate(
'xpack.apm.comparison.not.support',
{
defaultMessage: 'Comparison is not supported',
}
)}
size="m"
type="alert"
color="warning"
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup direction={rowDirection} gutterSize="s">
{/* Device */}
<EuiFlexItem>
<MostUsedChart
title={i18n.translate(
'xpack.apm.serviceOverview.mostUsed.device',
{
defaultMessage: 'Devices',
}
)}
metric={DEVICE_MODEL_IDENTIFIER}
start={start}
end={end}
kuery={kueryWithMobileFilters}
filters={embeddableFilters}
/>
</EuiFlexItem>
{/* NCT */}
<EuiFlexItem>
<MostUsedChart
title={i18n.translate(
'xpack.apm.serviceOverview.mostUsed.nct',
{
defaultMessage: 'Network Connection Type',
}
)}
metric={NETWORK_CONNECTION_TYPE}
start={start}
end={end}
kuery={kueryWithMobileFilters}
filters={embeddableFilters}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
{/* OS version */}
<EuiFlexItem>
<MostUsedChart
title={i18n.translate(
'xpack.apm.serviceOverview.mostUsed.osVersion',
{
defaultMessage: 'OS version',
}
)}
metric={HOST_OS_VERSION}
start={start}
end={end}
kuery={kueryWithMobileFilters}
filters={embeddableFilters}
/>
</EuiFlexItem>
{/* App version */}
<EuiFlexItem>
<MostUsedChart
title={i18n.translate(
'xpack.apm.serviceOverview.mostUsed.appVersion',
{
defaultMessage: 'App version',
}
)}
metric={SERVICE_VERSION}
start={start}
end={end}
kuery={kueryWithMobileFilters}
filters={embeddableFilters}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
<EuiFlexItem grow={4}>
<MobileLocationStats
start={start}
end={end}
kuery={kueryWithMobileFilters}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiPanel hasBorder={true} color="subdued">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.apm.serviceOverview.mostUsedTitle', {
defaultMessage: 'Most used',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiSpacer size="xs" />
<EuiFlexGroup direction={rowDirection} gutterSize="s">
{/* Device */}
<EuiFlexItem>
<EuiPanel hasBorder={true}>
<MostUsedChart
title={i18n.translate(
'xpack.apm.serviceOverview.mostUsed.device',
{
defaultMessage: 'Devices',
}
)}
metric={DEVICE_MODEL_IDENTIFIER}
start={start}
end={end}
kuery={kueryWithMobileFilters}
filters={embeddableFilters}
/>
</EuiPanel>
</EuiFlexItem>
{/* NCT */}
<EuiFlexItem>
<EuiPanel hasBorder={true}>
<MostUsedChart
title={i18n.translate(
'xpack.apm.serviceOverview.mostUsed.nct',
{
defaultMessage: 'Network Connection Type',
}
)}
metric={NETWORK_CONNECTION_TYPE}
start={start}
end={end}
kuery={kueryWithMobileFilters}
filters={embeddableFilters}
/>
</EuiPanel>
</EuiFlexItem>
<EuiSpacer size="s" />
{/* OS version */}
<EuiFlexItem>
<EuiPanel hasBorder={true}>
<MostUsedChart
title={i18n.translate(
'xpack.apm.serviceOverview.mostUsed.osVersion',
{
defaultMessage: 'OS version',
}
)}
metric={HOST_OS_VERSION}
start={start}
end={end}
kuery={kueryWithMobileFilters}
filters={embeddableFilters}
/>
</EuiPanel>
</EuiFlexItem>
{/* App version */}
<EuiFlexItem>
<EuiPanel hasBorder={true}>
<MostUsedChart
title={i18n.translate(
'xpack.apm.serviceOverview.mostUsed.appVersion',
{
defaultMessage: 'App version',
}
)}
metric={SERVICE_VERSION}
start={start}
end={end}
kuery={kueryWithMobileFilters}
filters={embeddableFilters}
/>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem>
<EuiPanel hasBorder={true}>
<LatencyChart

View file

@ -92,6 +92,7 @@ function EmbeddedMapComponent({
),
filters,
viewMode: ViewMode.VIEW,
mapCenter: { lat: 20.43425, lon: 0, zoom: 1.25 },
isLayerTOCOpen: false,
query: {
query: kuery,
@ -157,7 +158,7 @@ function EmbeddedMapComponent({
data-test-subj="serviceOverviewEmbeddedMap"
css={css`
width: 100%;
height: 400px;
height: 500px;
display: flex;
flex: 1 1 100%;
z-index: 1;

View file

@ -0,0 +1,191 @@
/*
* 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 { MetricDatum, MetricTrendShape } from '@elastic/charts';
import { i18n } from '@kbn/i18n';
import { EuiIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useCallback } from 'react';
import { useTheme } from '@kbn/observability-plugin/public';
import { useAnyOfApmParams } from '../../../../../hooks/use_apm_params';
import { useFetcher, isPending } from '../../../../../hooks/use_fetcher';
import { CLIENT_GEO_COUNTRY_NAME } from '../../../../../../common/es_fields/apm';
import { NOT_AVAILABLE_LABEL } from '../../../../../../common/i18n';
import { MetricItem } from './metric_item';
import { usePreviousPeriodLabel } from '../../../../../hooks/use_previous_period_text';
const getIcon =
(type: string) =>
({
width = 20,
height = 20,
color,
}: {
width: number;
height: number;
color: string;
}) =>
<EuiIcon type={type} width={width} height={height} fill={color} />;
const formatDifference = (value: number) => {
return value > 0 ? '+' + value.toFixed(0) + '%' : value.toFixed(0) + '%';
};
const calculateDiffPercentageAndFormat = (
currentValue?: number,
previousValue?: number
) => {
if (currentValue && previousValue) {
const diffPercentageValue =
((currentValue - previousValue) / previousValue) * 100;
return formatDifference(diffPercentageValue);
}
return 0;
};
export function MobileLocationStats({
start,
end,
kuery,
}: {
start: string;
end: string;
kuery: string;
}) {
const euiTheme = useTheme();
const {
path: { serviceName },
query: { environment, offset, comparisonEnabled },
} = useAnyOfApmParams('/mobile-services/{serviceName}/overview');
const previousPeriodLabel = usePreviousPeriodLabel();
const locationField = CLIENT_GEO_COUNTRY_NAME;
const { data: locationStatsData, status: locationStatsStatus } = useFetcher(
(callApmApi) => {
return callApmApi(
'GET /internal/apm/mobile-services/{serviceName}/location/stats',
{
params: {
path: { serviceName },
query: {
start,
end,
environment,
kuery,
locationField,
offset,
},
},
}
);
},
[start, end, environment, kuery, serviceName, locationField, offset]
);
const loadingLocationStats = isPending(locationStatsStatus);
const currentPeriod = locationStatsData?.currentPeriod;
const previousPeriod = locationStatsData?.previousPeriod;
const getComparisonValueFormatter = useCallback(
({ currentPeriodValue, previousPeriodValue }) => {
const comparisonDiffValue = calculateDiffPercentageAndFormat(
currentPeriodValue,
previousPeriodValue
);
if (comparisonDiffValue && comparisonEnabled) {
return (
<span>
{currentPeriodValue} ({comparisonDiffValue} {previousPeriodLabel})
</span>
);
}
return <span>{currentPeriodValue ? `${currentPeriodValue}` : null}</span>;
},
[comparisonEnabled, previousPeriodLabel]
);
const metrics: MetricDatum[] = [
{
color: euiTheme.eui.euiColorLightestShade,
title: i18n.translate(
'xpack.apm.mobile.location.metrics.http.requests.title',
{
defaultMessage: 'Most used in',
}
),
extra: getComparisonValueFormatter({
currentPeriodValue: currentPeriod?.mostRequests.value,
previousPeriodValue: previousPeriod?.mostRequests.value,
}),
icon: getIcon('visBarHorizontal'),
value: currentPeriod?.mostRequests.location ?? NOT_AVAILABLE_LABEL,
valueFormatter: (value) => `${value}`,
trend: currentPeriod?.mostRequests.timeseries,
trendShape: MetricTrendShape.Area,
},
{
color: euiTheme.eui.euiColorDisabled,
title: i18n.translate('xpack.apm.mobile.location.metrics.crashes', {
defaultMessage: 'Most crashes',
}),
subtitle: i18n.translate('xpack.apm.mobile.coming.soon', {
defaultMessage: 'Coming Soon',
}),
icon: getIcon('bug'),
value: NOT_AVAILABLE_LABEL,
valueFormatter: (value) => `${value}`,
trend: [],
trendShape: MetricTrendShape.Area,
},
{
color: euiTheme.eui.euiColorLightestShade,
title: i18n.translate('xpack.apm.mobile.location.metrics.sessions', {
defaultMessage: 'Most sessions',
}),
extra: getComparisonValueFormatter({
currentPeriodValue: currentPeriod?.mostSessions.value,
previousPeriodValue: previousPeriod?.mostSessions.value,
}),
icon: getIcon('timeslider'),
value: currentPeriod?.mostSessions.location ?? NOT_AVAILABLE_LABEL,
valueFormatter: (value) => `${value}`,
trend: currentPeriod?.mostSessions.timeseries,
trendShape: MetricTrendShape.Area,
},
{
color: euiTheme.eui.euiColorDisabled,
title: i18n.translate('xpack.apm.mobile.location.metrics.launches', {
defaultMessage: 'Most launches',
}),
subtitle: i18n.translate('xpack.apm.mobile.coming.soon', {
defaultMessage: 'Coming Soon',
}),
icon: getIcon('launch'),
value: NOT_AVAILABLE_LABEL,
valueFormatter: (value) => `${value}`,
trend: [],
trendShape: MetricTrendShape.Area,
},
];
return (
<EuiFlexGroup direction="column">
{metrics.map((metric, key) => (
<EuiFlexItem key={key}>
<MetricItem
id={key}
data={[metric]}
isLoading={loadingLocationStats}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
}

View file

@ -7,30 +7,31 @@
import React from 'react';
import { Chart, Metric, MetricDatum } from '@elastic/charts';
import { EuiLoadingContent, EuiPanel } from '@elastic/eui';
import { FETCH_STATUS, isPending } from '../../../../../hooks/use_fetcher';
import { isEmpty } from 'lodash';
export function MetricItem({
data,
id,
status,
hasData,
isLoading,
height = '124px',
}: {
data: MetricDatum[];
id: number;
status: FETCH_STATUS;
hasData: boolean;
isLoading: boolean;
height?: string;
}) {
const hasData = !isEmpty(data);
return (
<div
style={{
resize: 'none',
padding: '0px',
overflow: 'auto',
height: '120px',
height,
borderRadius: '6px',
}}
>
{!hasData && isPending(status) ? (
{!hasData && isLoading ? (
<EuiPanel hasBorder={true}>
<EuiLoadingContent lines={3} />
</EuiPanel>

View file

@ -14,9 +14,12 @@ import {
} from '@elastic/eui';
import React, { useCallback } from 'react';
import { useTheme } from '@kbn/observability-plugin/public';
import { isEmpty } from 'lodash';
import { useAnyOfApmParams } from '../../../../../hooks/use_apm_params';
import { useFetcher, FETCH_STATUS } from '../../../../../hooks/use_fetcher';
import {
useFetcher,
FETCH_STATUS,
isPending,
} from '../../../../../hooks/use_fetcher';
import { MetricItem } from './metric_item';
import { usePreviousPeriodLabel } from '../../../../../hooks/use_previous_period_text';
@ -97,6 +100,8 @@ export function MobileStats({
[status]
);
const loadingStats = isPending(status);
const metrics: MetricDatum[] = [
{
color: euiTheme.eui.euiColorDisabled,
@ -155,13 +160,8 @@ export function MobileStats({
return (
<EuiFlexGroup>
{metrics.map((metric, key) => (
<EuiFlexItem>
<MetricItem
id={key}
data={[metric]}
hasData={!isEmpty(data)}
status={status}
/>
<EuiFlexItem key={key}>
<MetricItem id={key} data={[metric]} isLoading={loadingStats} />
</EuiFlexItem>
))}
</EuiFlexGroup>

View file

@ -13,8 +13,9 @@ import {
} from '@kbn/observability-plugin/server';
import {
SERVICE_NAME,
SERVICE_TARGET_TYPE,
TRANSACTION_NAME,
SPAN_TYPE,
SPAN_SUBTYPE,
} from '../../../common/es_fields/apm';
import { environmentQuery } from '../../../common/utils/environment_query';
import { getBucketSize } from '../../../common/utils/get_bucket_size';
@ -23,7 +24,6 @@ import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_pr
import { Maybe } from '../../../typings/common';
import { Coordinate } from '../../../typings/timeseries';
import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client';
import { getDocumentTypeFilterForServiceDestinationStatistics } from '../../lib/helpers/spans/get_is_using_service_destination_metrics';
export interface HttpRequestsTimeseries {
currentPeriod: { timeseries: Coordinate[]; value: Maybe<number> };
@ -64,21 +64,21 @@ async function getHttpRequestsTimeseries({
const aggs = {
requests: {
filter: { term: { [SERVICE_TARGET_TYPE]: 'http' } },
filter: { term: { [SPAN_SUBTYPE]: 'http' } },
},
};
const response = await apmEventClient.search('get_http_requests_chart', {
apm: { events: [ProcessorEvent.metric] },
apm: { events: [ProcessorEvent.span] },
body: {
track_total_hits: false,
size: 0,
query: {
bool: {
filter: [
{ exists: { field: SERVICE_TARGET_TYPE } },
...getDocumentTypeFilterForServiceDestinationStatistics(true),
{ exists: { field: SPAN_SUBTYPE } },
...termQuery(SERVICE_NAME, serviceName),
...termQuery(SPAN_TYPE, 'external'),
...termQuery(TRANSACTION_NAME, transactionName),
...rangeQuery(startWithOffset, endWithOffset),
...environmentQuery(environment),

View file

@ -0,0 +1,119 @@
/*
* 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 {
termQuery,
kqlQuery,
rangeQuery,
} from '@kbn/observability-plugin/server';
import { ProcessorEvent } from '@kbn/observability-plugin/common';
import {
SERVICE_NAME,
SPAN_TYPE,
SPAN_SUBTYPE,
} from '../../../common/es_fields/apm';
import { environmentQuery } from '../../../common/utils/environment_query';
import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client';
import { getOffsetInMs } from '../../../common/utils/get_offset_in_ms';
import { getBucketSize } from '../../../common/utils/get_bucket_size';
interface Props {
kuery: string;
apmEventClient: APMEventClient;
serviceName: string;
environment: string;
start: number;
end: number;
locationField?: string;
offset?: string;
}
export async function getHttpRequestsByLocation({
kuery,
apmEventClient,
serviceName,
environment,
start,
end,
locationField,
offset,
}: Props) {
const { startWithOffset, endWithOffset } = getOffsetInMs({
start,
end,
offset,
});
const { intervalString } = getBucketSize({
start: startWithOffset,
end: endWithOffset,
minBucketSize: 60,
});
const aggs = {
requests: {
filter: { term: { [SPAN_SUBTYPE]: 'http' } },
aggs: {
requestsByLocation: {
terms: {
field: locationField,
},
},
},
},
};
const response = await apmEventClient.search(
'get_mobile_location_http_requests',
{
apm: {
events: [ProcessorEvent.span],
},
body: {
track_total_hits: false,
size: 0,
query: {
bool: {
filter: [
...termQuery(SERVICE_NAME, serviceName),
...termQuery(SPAN_TYPE, 'external'),
...rangeQuery(startWithOffset, endWithOffset),
...environmentQuery(environment),
...kqlQuery(kuery),
],
},
},
aggs: {
timeseries: {
date_histogram: {
field: '@timestamp',
fixed_interval: intervalString,
min_doc_count: 0,
},
aggs,
},
...aggs,
},
},
}
);
return {
location: response.aggregations?.requests?.requestsByLocation?.buckets[0]
?.key as string,
value:
response.aggregations?.requests?.requestsByLocation?.buckets[0]
?.doc_count ?? 0,
timeseries:
response.aggregations?.timeseries?.buckets.map((bucket) => ({
x: bucket.key,
y:
response.aggregations?.requests?.requestsByLocation?.buckets[0]
?.doc_count ?? 0,
})) ?? [],
};
}

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 { CLIENT_GEO_COUNTRY_NAME } from '../../../common/es_fields/apm';
import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client';
import { getSessionsByLocation } from './get_mobile_sessions_by_location';
import { getHttpRequestsByLocation } from './get_mobile_http_requests_by_location';
import { Maybe } from '../../../typings/common';
export type Timeseries = Array<{ x: number; y: number }>;
interface LocationStats {
mostSessions: {
location?: string;
value: Maybe<number>;
timeseries: Timeseries;
};
mostRequests: {
location?: string;
value: Maybe<number>;
timeseries: Timeseries;
};
}
export interface MobileLocationStats {
currentPeriod: LocationStats;
previousPeriod: LocationStats;
}
interface Props {
kuery: string;
apmEventClient: APMEventClient;
serviceName: string;
environment: string;
start: number;
end: number;
locationField?: string;
offset?: string;
}
async function getMobileLocationStats({
kuery,
apmEventClient,
serviceName,
environment,
start,
end,
locationField = CLIENT_GEO_COUNTRY_NAME,
offset,
}: Props) {
const commonProps = {
kuery,
apmEventClient,
serviceName,
environment,
start,
end,
locationField,
offset,
};
const [mostSessions, mostRequests] = await Promise.all([
getSessionsByLocation({ ...commonProps }),
getHttpRequestsByLocation({ ...commonProps }),
]);
return {
mostSessions,
mostRequests,
};
}
export async function getMobileLocationStatsPeriods({
kuery,
apmEventClient,
serviceName,
environment,
start,
end,
locationField,
offset,
}: Props): Promise<MobileLocationStats> {
const commonProps = {
kuery,
apmEventClient,
serviceName,
environment,
locationField,
};
const currentPeriodPromise = getMobileLocationStats({
...commonProps,
start,
end,
});
const previousPeriodPromise = offset
? getMobileLocationStats({
...commonProps,
start,
end,
offset,
})
: {
mostSessions: { value: null, timeseries: [] },
mostRequests: { value: null, timeseries: [] },
};
const [currentPeriod, previousPeriod] = await Promise.all([
currentPeriodPromise,
previousPeriodPromise,
]);
return {
currentPeriod,
previousPeriod,
};
}

View file

@ -0,0 +1,106 @@
/*
* 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 {
termQuery,
kqlQuery,
rangeQuery,
} from '@kbn/observability-plugin/server';
import { ProcessorEvent } from '@kbn/observability-plugin/common';
import { SERVICE_NAME, SESSION_ID } from '../../../common/es_fields/apm';
import { environmentQuery } from '../../../common/utils/environment_query';
import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client';
import { getOffsetInMs } from '../../../common/utils/get_offset_in_ms';
import { getBucketSize } from '../../../common/utils/get_bucket_size';
interface Props {
kuery: string;
apmEventClient: APMEventClient;
serviceName: string;
environment: string;
start: number;
end: number;
locationField?: string;
offset?: string;
}
export async function getSessionsByLocation({
kuery,
apmEventClient,
serviceName,
environment,
start,
end,
locationField,
offset,
}: Props) {
const { startWithOffset, endWithOffset } = getOffsetInMs({
start,
end,
offset,
});
const { intervalString } = getBucketSize({
start: startWithOffset,
end: endWithOffset,
minBucketSize: 60,
});
const aggs = {
sessions: {
terms: {
field: locationField,
},
aggs: {
sessions: {
cardinality: { field: SESSION_ID },
},
},
},
};
const response = await apmEventClient.search('get_mobile_location_sessions', {
apm: {
events: [ProcessorEvent.transaction],
},
body: {
track_total_hits: false,
size: 0,
query: {
bool: {
filter: [
...termQuery(SERVICE_NAME, serviceName),
...rangeQuery(startWithOffset, endWithOffset),
...environmentQuery(environment),
...kqlQuery(kuery),
],
},
},
aggs: {
timeseries: {
date_histogram: {
field: '@timestamp',
fixed_interval: intervalString,
min_doc_count: 0,
},
aggs,
},
...aggs,
},
},
});
return {
location: response.aggregations?.sessions?.buckets[0]?.key as string,
value: response.aggregations?.sessions?.buckets[0]?.sessions.value ?? 0,
timeseries:
response.aggregations?.timeseries?.buckets.map((bucket) => ({
x: bucket.key,
y: bucket.sessions.buckets[0]?.sessions.value ?? 0,
})) ?? [],
};
}

View file

@ -17,6 +17,10 @@ import {
import { getMobileFilters } from './get_mobile_filters';
import { getMobileSessions, SessionsTimeseries } from './get_mobile_sessions';
import { getMobileStatsPeriods, MobilePeriodStats } from './get_mobile_stats';
import {
getMobileLocationStatsPeriods,
MobileLocationStats,
} from './get_mobile_location_stats';
const mobileFiltersRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/services/{serviceName}/mobile/filters',
@ -94,6 +98,45 @@ const mobileStatsRoute = createApmServerRoute({
},
});
const mobileLocationStatsRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/mobile-services/{serviceName}/location/stats',
params: t.type({
path: t.type({
serviceName: t.string,
}),
query: t.intersection([
kueryRt,
rangeRt,
environmentRt,
offsetRt,
t.partial({
locationField: t.string,
}),
]),
}),
options: { tags: ['access:apm'] },
handler: async (resources): Promise<MobileLocationStats> => {
const apmEventClient = await getApmEventClient(resources);
const { params } = resources;
const { serviceName } = params.path;
const { kuery, environment, start, end, locationField, offset } =
params.query;
const locationStats = await getMobileLocationStatsPeriods({
kuery,
environment,
start,
end,
serviceName,
apmEventClient,
locationField,
offset,
});
return locationStats;
},
});
const sessionsChartRoute = createApmServerRoute({
endpoint:
'GET /internal/apm/mobile-services/{serviceName}/transactions/charts/sessions',
@ -181,4 +224,5 @@ export const mobileRouteRepository = {
...sessionsChartRoute,
...httpRequestsChartRoute,
...mobileStatsRoute,
...mobileLocationStatsRoute,
};

View file

@ -144,7 +144,9 @@ export async function generateMobileData({
.span({
spanName: 'onCreate',
spanType: 'app',
spanSubtype: 'internal',
spanSubtype: 'external',
'service.target.type': 'http',
'span.destination.service.resource': 'external',
})
.duration(50)
.success()

View file

@ -92,7 +92,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
response.body.currentPeriod.timeseries.some((item) => item.y === 0 && item.x)
).to.eql(true);
expect(response.body.currentPeriod.timeseries[0].y).to.eql(1);
expect(response.body.currentPeriod.timeseries[0].y).to.eql(3);
expect(response.body.previousPeriod.timeseries).to.eql([]);
});
});
@ -125,8 +125,8 @@ export default function ApiTest({ getService }: FtrProviderContext) {
expect(response.status).to.be(200);
expect(ntcCell.status).to.be(200);
expect(response.body.currentPeriod.timeseries[0].y).to.eql(0);
expect(ntcCell.body.currentPeriod.timeseries[0].y).to.eql(0);
expect(response.body.currentPeriod.timeseries[0].y).to.eql(2);
expect(ntcCell.body.currentPeriod.timeseries[0].y).to.eql(1);
});
});
});

View file

@ -0,0 +1,140 @@
/*
* 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 expect from '@kbn/expect';
import { APIReturnType } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
import { ENVIRONMENT_ALL } from '@kbn/apm-plugin/common/environment_filter_values';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { generateMobileData } from './generate_mobile_data';
type MobileLocationStats =
APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/location/stats'>;
export default function ApiTest({ getService }: FtrProviderContext) {
const apmApiClient = getService('apmApiClient');
const registry = getService('registry');
const synthtraceEsClient = getService('synthtraceEsClient');
const start = new Date('2023-01-01T00:00:00.000Z').getTime();
const end = new Date('2023-01-01T00:15:00.000Z').getTime() - 1;
async function getMobileLocationStats({
environment = ENVIRONMENT_ALL.value,
kuery = '',
serviceName,
locationField = 'client.geo.country_name',
}: {
environment?: string;
kuery?: string;
serviceName: string;
locationField?: string;
}) {
return await apmApiClient
.readUser({
endpoint: 'GET /internal/apm/mobile-services/{serviceName}/location/stats',
params: {
path: { serviceName },
query: {
environment,
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
kuery,
locationField,
},
},
})
.then(({ body }) => body);
}
registry.when('Location stats when data is not loaded', { config: 'basic', archives: [] }, () => {
describe('when no data', () => {
it('handles empty state', async () => {
const response = await getMobileLocationStats({ serviceName: 'foo' });
expect(response.currentPeriod.mostSessions.timeseries.every((item) => item.y === 0)).to.eql(
true
);
expect(response.currentPeriod.mostRequests.timeseries.every((item) => item.y === 0)).to.eql(
true
);
});
});
});
registry.when('Location stats', { config: 'basic', archives: [] }, () => {
before(async () => {
await generateMobileData({
synthtraceEsClient,
start,
end,
});
});
after(() => synthtraceEsClient.clean());
describe('when data is loaded', () => {
let response: MobileLocationStats;
before(async () => {
response = await getMobileLocationStats({
serviceName: 'synth-android',
environment: 'production',
});
});
it('returns location for most sessions', () => {
const { location } = response.currentPeriod.mostSessions;
expect(location).to.be('China');
});
it('returns location for most requests', () => {
const { location } = response.currentPeriod.mostRequests;
expect(location).to.be('China');
});
});
describe('when filters are applied', () => {
it('returns empty state for filters', async () => {
const response = await getMobileLocationStats({
serviceName: 'synth-android',
environment: 'production',
kuery: `app.version:"none"`,
});
expect(response.currentPeriod.mostSessions.value).to.eql(0);
expect(response.currentPeriod.mostRequests.value).to.eql(0);
expect(response.currentPeriod.mostSessions.timeseries.every((item) => item.y === 0)).to.eql(
true
);
expect(response.currentPeriod.mostRequests.timeseries.every((item) => item.y === 0)).to.eql(
true
);
});
it('returns the correct values when single filter is applied', async () => {
const response = await getMobileLocationStats({
serviceName: 'synth-android',
environment: 'production',
kuery: `service.version:"1.0"`,
});
expect(response.currentPeriod.mostSessions.value).to.eql(6);
expect(response.currentPeriod.mostRequests.value).to.eql(0);
});
it('returns the correct values when multiple filters are applied', async () => {
const response = await getMobileLocationStats({
serviceName: 'synth-android',
kuery: `service.version:"1.0" and service.environment: "production"`,
});
expect(response.currentPeriod.mostSessions.value).to.eql(6);
expect(response.currentPeriod.mostRequests.value).to.eql(0);
});
});
});
}