[APM] Add table tabs showing summary of metrics (#153044)

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

Notes:
- Crash rate is calculated per sessions, the same session ID is kept
after a crash, so a session can have more than one crash.
- App number of launches is not available
- Error rate stays out for now
- Http requests, `host.os.version` and `service.version` is only
available on transactions, metrics and errors. Not spans for now, there
are two issues opened to fix this for the apm mobile agents team
- Instead of the View Load (not available), we show Throughput 
- The filters (+ -) will be added in a follow-up PR

Pending:
- [x] API tests
- [x] e2e tests


https://user-images.githubusercontent.com/31922082/234267965-e5e1e411-87c6-40b8-9e94-31d792f9d806.mov

---------

Co-authored-by: Yngrid Coello <yngrid.coello@elastic.co>
This commit is contained in:
Miriam 2023-04-25 15:43:21 +01:00 committed by GitHub
parent 3864554b36
commit b6a91f318e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1826 additions and 64 deletions

View file

@ -22,6 +22,7 @@ export type ApmApplicationMetricFields = Partial<{
'faas.timeout': number;
'faas.coldstart_duration': number;
'faas.duration': number;
'application.launch.time': number;
}>;
export type ApmUserAgentFields = Partial<{
@ -88,6 +89,7 @@ export type ApmFields = Fields<{
'error.grouping_key': string;
'error.grouping_name': string;
'error.id': string;
'error.type': string;
'event.ingested': number;
'event.name': string;
'event.outcome': string;

View file

@ -9,8 +9,10 @@
import { Entity } from '../entity';
import { Span } from './span';
import { Transaction } from './transaction';
import { ApmFields, SpanParams, GeoLocation } from './apm_fields';
import { ApmFields, SpanParams, GeoLocation, ApmApplicationMetricFields } from './apm_fields';
import { generateLongId } from '../utils/generate_id';
import { Metricset } from './metricset';
import { ApmError } from './apm_error';
export interface DeviceInfo {
manufacturer: string;
@ -115,6 +117,7 @@ export class MobileDevice extends Entity<ApmFields> {
return this;
}
// FIXME synthtrace shouldn't have side-effects like this. We should use an API like .session() which returns a session
startNewSession() {
this.fields['session.id'] = generateLongId();
return this;
@ -238,4 +241,21 @@ export class MobileDevice extends Entity<ApmFields> {
return this.span(spanParameters);
}
appMetrics(metrics: ApmApplicationMetricFields) {
return new Metricset<ApmFields>({
...this.fields,
'metricset.name': 'app',
...metrics,
});
}
crash({ message, groupingName }: { message: string; groupingName?: string }) {
return new ApmError({
...this.fields,
'error.type': 'crash',
'error.exception': [{ message, ...{ type: 'crash' } }],
'error.grouping_name': groupingName || message,
});
}
}

View file

@ -20,6 +20,16 @@ const ENVIRONMENT = getSynthtraceEnvironment(__filename);
type DeviceMetadata = DeviceInfo & OSInfo;
const modelIdentifiersWithCrashes = [
'SM-G930F',
'HUAWEI P2-0000',
'Pixel 3a',
'LG K10',
'iPhone11,8',
'Watch6,8',
'iPad12,2',
];
const ANDROID_DEVICES: DeviceMetadata[] = [
{
manufacturer: 'Samsung',
@ -354,34 +364,40 @@ const scenario: Scenario<ApmFields> = async ({ scenarioOpts, logger }) => {
device.startNewSession();
const framework =
device.fields['device.manufacturer'] === 'Apple' ? 'iOS' : 'Android Activity';
const couldCrash = modelIdentifiersWithCrashes.includes(
device.fields['device.model.identifier'] ?? ''
);
const startTx = device
.transaction('Start View - View Appearing', framework)
.timestamp(timestamp)
.duration(500)
.success()
.children(
device
.span({
spanName: 'onCreate',
spanType: 'app',
spanSubtype: 'external',
'service.target.type': 'http',
'span.destination.service.resource': 'external',
})
.duration(50)
.success()
.timestamp(timestamp + 20),
device
.httpSpan({
spanName: 'GET backend:1234',
httpMethod: 'GET',
httpUrl: 'https://backend:1234/api/start',
})
.duration(800)
.failure()
.timestamp(timestamp + 400)
);
return [
device
.transaction('Start View - View Appearing', framework)
.timestamp(timestamp)
.duration(500)
.success()
.children(
device
.span({
spanName: 'onCreate',
spanType: 'app',
spanSubtype: 'external',
'service.target.type': 'http',
'span.destination.service.resource': 'external',
})
.duration(50)
.success()
.timestamp(timestamp + 20),
device
.httpSpan({
spanName: 'GET backend:1234',
httpMethod: 'GET',
httpUrl: 'https://backend:1234/api/start',
})
.duration(800)
.failure()
.timestamp(timestamp + 400)
),
couldCrash && index % 2 === 0
? startTx.errors(device.crash({ message: 'error' }).timestamp(timestamp))
: startTx,
device
.transaction('Second View - View Appearing', framework)
.timestamp(10000 + timestamp)
@ -418,7 +434,23 @@ const scenario: Scenario<ApmFields> = async ({ scenarioOpts, logger }) => {
});
};
return [...androidDevices, ...iOSDevices].map((device) => sessionTransactions(device));
const appLaunchMetrics = (device: MobileDevice) => {
return clickRate.generator((timestamp, index) =>
device
.appMetrics({
'application.launch.time': 100 * (index + 1),
})
.timestamp(timestamp)
);
};
return [
...androidDevices.flatMap((device) => [
sessionTransactions(device),
appLaunchMetrics(device),
]),
...iOSDevices.map((device) => sessionTransactions(device)),
];
},
};
};

View file

@ -13,7 +13,8 @@ type AnyApmDocumentType =
| ApmDocumentType.TransactionMetric
| ApmDocumentType.TransactionEvent
| ApmDocumentType.ServiceDestinationMetric
| ApmDocumentType.ServiceSummaryMetric;
| ApmDocumentType.ServiceSummaryMetric
| ApmDocumentType.ErrorEvent;
export interface ApmDataSource<
TDocumentType extends AnyApmDocumentType = AnyApmDocumentType

View file

@ -11,6 +11,7 @@ export enum ApmDocumentType {
TransactionEvent = 'transactionEvent',
ServiceDestinationMetric = 'serviceDestinationMetric',
ServiceSummaryMetric = 'serviceSummaryMetric',
ErrorEvent = 'error',
}
export type ApmServiceTransactionDocumentType =

View file

@ -90,6 +90,8 @@ exports[`Error ERROR_LOG_MESSAGE 1`] = `undefined`;
exports[`Error ERROR_PAGE_URL 1`] = `undefined`;
exports[`Error ERROR_TYPE 1`] = `undefined`;
exports[`Error EVENT_NAME 1`] = `undefined`;
exports[`Error EVENT_OUTCOME 1`] = `undefined`;
@ -417,6 +419,8 @@ exports[`Span ERROR_LOG_MESSAGE 1`] = `undefined`;
exports[`Span ERROR_PAGE_URL 1`] = `undefined`;
exports[`Span ERROR_TYPE 1`] = `undefined`;
exports[`Span EVENT_NAME 1`] = `undefined`;
exports[`Span EVENT_OUTCOME 1`] = `"unknown"`;
@ -740,6 +744,8 @@ exports[`Transaction ERROR_LOG_MESSAGE 1`] = `undefined`;
exports[`Transaction ERROR_PAGE_URL 1`] = `undefined`;
exports[`Transaction ERROR_TYPE 1`] = `undefined`;
exports[`Transaction EVENT_NAME 1`] = `undefined`;
exports[`Transaction EVENT_OUTCOME 1`] = `"unknown"`;

View file

@ -109,6 +109,7 @@ export const ERROR_EXC_MESSAGE = 'error.exception.message'; // only to be used i
export const ERROR_EXC_HANDLED = 'error.exception.handled'; // only to be used in es queries, since error.exception is now an array
export const ERROR_EXC_TYPE = 'error.exception.type';
export const ERROR_PAGE_URL = 'error.page.url';
export const ERROR_TYPE = 'error.type';
// METRICS
export const METRIC_SYSTEM_FREE_MEMORY = 'system.memory.actual.free';

View file

@ -0,0 +1,221 @@
/*
* 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 { apm, timerange } from '@kbn/apm-synthtrace-client';
export const SERVICE_VERSIONS = ['2.3', '1.2', '1.1'];
export function generateMobileData({ from, to }: { from: number; to: number }) {
const galaxy10 = apm
.mobileApp({
name: 'synth-android',
environment: 'production',
agentName: 'android/java',
})
.mobileDevice({ serviceVersion: SERVICE_VERSIONS[0] })
.deviceInfo({
manufacturer: 'Samsung',
modelIdentifier: 'SM-G973F',
modelName: 'Galaxy S10',
})
.osInfo({
osType: 'android',
osVersion: '10',
osFull: 'Android 10, API level 29, BUILD A022MUBU2AUD1',
runtimeVersion: '2.1.0',
})
.setGeoInfo({
clientIp: '223.72.43.22',
cityName: 'Beijing',
continentName: 'Asia',
countryIsoCode: 'CN',
countryName: 'China',
regionIsoCode: 'CN-BJ',
regionName: 'Beijing',
location: { coordinates: [116.3861, 39.9143], type: 'Point' },
})
.setNetworkConnection({ type: 'wifi' });
const galaxy7 = apm
.mobileApp({
name: 'synth-android',
environment: 'production',
agentName: 'android/java',
})
.mobileDevice({ serviceVersion: SERVICE_VERSIONS[1] })
.deviceInfo({
manufacturer: 'Samsung',
modelIdentifier: 'SM-G930F',
modelName: 'Galaxy S7',
})
.osInfo({
osType: 'android',
osVersion: '10',
osFull: 'Android 10, API level 29, BUILD A022MUBU2AUD1',
runtimeVersion: '2.1.0',
})
.setGeoInfo({
clientIp: '223.72.43.22',
cityName: 'Beijing',
continentName: 'Asia',
countryIsoCode: 'CN',
countryName: 'China',
regionIsoCode: 'CN-BJ',
regionName: 'Beijing',
location: { coordinates: [116.3861, 39.9143], type: 'Point' },
})
.setNetworkConnection({
type: 'cell',
subType: 'edge',
carrierName: 'M1 Limited',
carrierMNC: '03',
carrierICC: 'SG',
carrierMCC: '525',
});
const huaweiP2 = apm
.mobileApp({
name: 'synth-android',
environment: 'production',
agentName: 'android/java',
})
.mobileDevice({ serviceVersion: SERVICE_VERSIONS[2] })
.deviceInfo({
manufacturer: 'Huawei',
modelIdentifier: 'HUAWEI P2-0000',
modelName: 'HuaweiP2',
})
.osInfo({
osType: 'android',
osVersion: '10',
osFull: 'Android 10, API level 29, BUILD A022MUBU2AUD1',
runtimeVersion: '2.1.0',
})
.setGeoInfo({
clientIp: '20.24.184.101',
cityName: 'Singapore',
continentName: 'Asia',
countryIsoCode: 'SG',
countryName: 'Singapore',
location: { coordinates: [103.8554, 1.3036], type: 'Point' },
})
.setNetworkConnection({
type: 'cell',
subType: 'edge',
carrierName: 'Osaka Gas Business Create Co., Ltd.',
carrierMNC: '17',
carrierICC: 'JP',
carrierMCC: '440',
});
return timerange(from, to)
.interval('5m')
.rate(1)
.generator((timestamp) => {
galaxy10.startNewSession();
galaxy7.startNewSession();
huaweiP2.startNewSession();
return [
galaxy10
.transaction('Start View - View Appearing', 'Android Activity')
.timestamp(timestamp)
.duration(500)
.success()
.children(
galaxy10
.span({
spanName: 'onCreate',
spanType: 'app',
spanSubtype: 'external',
'service.target.type': 'http',
'span.destination.service.resource': 'external',
})
.duration(50)
.success()
.timestamp(timestamp + 20),
galaxy10
.httpSpan({
spanName: 'GET backend:1234',
httpMethod: 'GET',
httpUrl: 'https://backend:1234/api/start',
})
.duration(800)
.success()
.timestamp(timestamp + 400)
),
galaxy10
.transaction('Second View - View Appearing', 'Android Activity')
.timestamp(10000 + timestamp)
.duration(300)
.failure()
.children(
galaxy10
.httpSpan({
spanName: 'GET backend:1234',
httpMethod: 'GET',
httpUrl: 'https://backend:1234/api/second',
})
.duration(400)
.success()
.timestamp(10000 + timestamp + 250)
),
huaweiP2
.transaction('Start View - View Appearing', 'huaweiP2 Activity')
.timestamp(timestamp)
.duration(20)
.success()
.children(
huaweiP2
.span({
spanName: 'onCreate',
spanType: 'app',
spanSubtype: 'external',
'service.target.type': 'http',
'span.destination.service.resource': 'external',
})
.duration(50)
.success()
.timestamp(timestamp + 20),
huaweiP2
.httpSpan({
spanName: 'GET backend:1234',
httpMethod: 'GET',
httpUrl: 'https://backend:1234/api/start',
})
.duration(800)
.success()
.timestamp(timestamp + 400)
),
galaxy7
.transaction('Start View - View Appearing', 'Android Activity')
.timestamp(timestamp)
.duration(20)
.success()
.children(
galaxy7
.span({
spanName: 'onCreate',
spanType: 'app',
spanSubtype: 'external',
'service.target.type': 'http',
'span.destination.service.resource': 'external',
})
.duration(50)
.success()
.timestamp(timestamp + 20),
galaxy7
.httpSpan({
spanName: 'GET backend:1234',
httpMethod: 'GET',
httpUrl: 'https://backend:1234/api/start',
})
.duration(800)
.success()
.timestamp(timestamp + 400)
),
];
});
}

View file

@ -0,0 +1,68 @@
/*
* 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 url from 'url';
import { synthtrace } from '../../../../synthtrace';
import { generateMobileData } from './generate_data';
const start = '2021-10-10T00:00:00.000Z';
const end = '2021-10-10T00:15:00.000Z';
const mobileTransactionsPageHref = url.format({
pathname: '/app/apm/mobile-services/synth-android/transactions',
query: {
rangeFrom: start,
rangeTo: end,
},
});
describe('Mobile transactions page', () => {
beforeEach(() => {
cy.loginAsViewerUser();
});
describe('when data is loaded', () => {
before(() => {
synthtrace.index(
generateMobileData({
from: new Date(start).getTime(),
to: new Date(end).getTime(),
})
);
});
after(() => {
synthtrace.clean();
});
describe('when click on tab shows correct table', () => {
it('shows version tab', () => {
cy.visitKibana(mobileTransactionsPageHref);
cy.getByTestSubj('apmAppVersionTab')
.click()
.should('have.attr', 'aria-selected', 'true');
cy.url().should('include', 'mobileSelectedTab=app_version_tab');
});
it('shows OS version tab', () => {
cy.visitKibana(mobileTransactionsPageHref);
cy.getByTestSubj('apmOsVersionTab')
.click()
.should('have.attr', 'aria-selected', 'true');
cy.url().should('include', 'mobileSelectedTab=os_version_tab');
});
it('shows devices tab', () => {
cy.visitKibana(mobileTransactionsPageHref);
cy.getByTestSubj('apmDevicesTab')
.click()
.should('have.attr', 'aria-selected', 'true');
cy.url().should('include', 'mobileSelectedTab=devices_tab');
});
});
});
});

View file

@ -16,11 +16,11 @@ import { useHistory } from 'react-router-dom';
import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { useTimeRange } from '../../../../hooks/use_time_range';
import { TransactionsTable } from '../../../shared/transactions_table';
import { replace } from '../../../shared/links/url_helpers';
import { getKueryWithMobileFilters } from '../../../../../common/utils/get_kuery_with_mobile_filters';
import { MobileTransactionCharts } from './transaction_charts';
import { MobileTreemap } from '../charts/mobile_treemap';
import { TransactionOverviewTabs } from './transaction_overview_tabs';
export function MobileTransactionOverview() {
const {
@ -37,6 +37,7 @@ export function MobileTransactionOverview() {
kuery,
offset,
comparisonEnabled,
mobileSelectedTab,
},
} = useApmParams('/mobile-services/{serviceName}/transactions');
@ -88,15 +89,14 @@ export function MobileTransactionOverview() {
/>
<EuiSpacer size="s" />
<EuiPanel hasBorder={true}>
<TransactionsTable
hideViewTransactionsLink
numberOfTransactionsPerPage={25}
showMaxTransactionGroupsExceededWarning
<TransactionOverviewTabs
environment={environment}
kuery={kueryWithMobileFilters}
start={start}
end={end}
saveTableOptionsToUrl
comparisonEnabled={comparisonEnabled}
offset={offset}
mobileSelectedTab={mobileSelectedTab}
/>
</EuiPanel>
</>

View file

@ -0,0 +1,61 @@
/*
* 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 from 'react';
import { i18n } from '@kbn/i18n';
import { TabContentProps } from '.';
import { isPending } from '../../../../../hooks/use_fetcher';
import { StatsList } from './stats_list';
import { SERVICE_VERSION } from '../../../../../../common/es_fields/apm';
import { useMobileStatisticsFetcher } from './use_mobile_statistics_fetcher';
function AppVersionTab({
environment,
kuery,
start,
end,
comparisonEnabled,
offset,
}: TabContentProps) {
const {
mainStatistics,
mainStatisticsStatus,
detailedStatistics,
detailedStatisticsStatus,
} = useMobileStatisticsFetcher({
field: SERVICE_VERSION,
environment,
kuery,
start,
end,
comparisonEnabled,
offset,
});
return (
<StatsList
isLoading={isPending(mainStatisticsStatus)}
mainStatistics={mainStatistics}
detailedStatisticsLoading={isPending(detailedStatisticsStatus)}
detailedStatistics={detailedStatistics}
comparisonEnabled={comparisonEnabled}
offset={offset}
/>
);
}
export const appVersionTab = {
dataTestSubj: 'apmAppVersionTab',
key: 'app_version_tab',
label: i18n.translate(
'xpack.apm.mobile.transactions.overview.tabs.app.version',
{
defaultMessage: 'App version',
}
),
component: AppVersionTab,
};

View file

@ -0,0 +1,58 @@
/*
* 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 from 'react';
import { i18n } from '@kbn/i18n';
import { TabContentProps } from '.';
import { isPending } from '../../../../../hooks/use_fetcher';
import { StatsList } from './stats_list';
import { useMobileStatisticsFetcher } from './use_mobile_statistics_fetcher';
import { DEVICE_MODEL_IDENTIFIER } from '../../../../../../common/es_fields/apm';
function DevicesTab({
environment,
kuery,
start,
end,
comparisonEnabled,
offset,
}: TabContentProps) {
const {
mainStatistics,
mainStatisticsStatus,
detailedStatistics,
detailedStatisticsStatus,
} = useMobileStatisticsFetcher({
field: DEVICE_MODEL_IDENTIFIER,
environment,
kuery,
start,
end,
comparisonEnabled,
offset,
});
return (
<StatsList
isLoading={isPending(mainStatisticsStatus)}
mainStatistics={mainStatistics}
detailedStatisticsLoading={isPending(detailedStatisticsStatus)}
detailedStatistics={detailedStatistics}
comparisonEnabled={comparisonEnabled}
offset={offset}
/>
);
}
export const devicesTab = {
dataTestSubj: 'apmDevicesTab',
key: 'devices_tab',
label: i18n.translate('xpack.apm.mobile.transactions.overview.tabs.devices', {
defaultMessage: 'Devices',
}),
component: DevicesTab,
};

View file

@ -0,0 +1,77 @@
/*
* 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 from 'react';
import { useHistory } from 'react-router-dom';
import { EuiSpacer, EuiTabs, EuiTab } from '@elastic/eui';
import { push } from '../../../../shared/links/url_helpers';
import { transactionsTab } from './transactions_tab';
import { osVersionTab } from './os_version_tab';
import { appVersionTab } from './app_version_tab';
import { devicesTab } from './devices_tab';
export interface TabContentProps {
agentName?: string;
environment: string;
start: string;
end: string;
kuery: string;
comparisonEnabled: boolean;
offset?: string;
mobileSelectedTab?: string;
}
const tabs = [transactionsTab, appVersionTab, osVersionTab, devicesTab];
export function TransactionOverviewTabs({
agentName,
environment,
start,
end,
kuery,
comparisonEnabled,
offset,
mobileSelectedTab,
}: TabContentProps) {
const history = useHistory();
const { component: TabContent } =
tabs.find((tab) => tab.key === mobileSelectedTab) ?? transactionsTab;
return (
<>
<EuiTabs>
{tabs.map(({ dataTestSubj, key, label }) => (
<EuiTab
data-test-subj={dataTestSubj}
key={key}
isSelected={key === mobileSelectedTab}
onClick={() => {
push(history, {
query: {
mobileSelectedTab: key,
},
});
}}
>
{label}
</EuiTab>
))}
</EuiTabs>
<EuiSpacer size="m" />
<TabContent
{...{
agentName,
environment,
start,
end,
kuery,
comparisonEnabled,
offset,
}}
/>
</>
);
}

View file

@ -0,0 +1,61 @@
/*
* 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 from 'react';
import { i18n } from '@kbn/i18n';
import { TabContentProps } from '.';
import { isPending } from '../../../../../hooks/use_fetcher';
import { StatsList } from './stats_list';
import { useMobileStatisticsFetcher } from './use_mobile_statistics_fetcher';
import { HOST_OS_VERSION } from '../../../../../../common/es_fields/apm';
function OSVersionTab({
environment,
kuery,
start,
end,
comparisonEnabled,
offset,
}: TabContentProps) {
const {
mainStatistics,
mainStatisticsStatus,
detailedStatistics,
detailedStatisticsStatus,
} = useMobileStatisticsFetcher({
field: HOST_OS_VERSION,
environment,
kuery,
start,
end,
comparisonEnabled,
offset,
});
return (
<StatsList
isLoading={isPending(mainStatisticsStatus)}
mainStatistics={mainStatistics}
detailedStatisticsLoading={isPending(detailedStatisticsStatus)}
detailedStatistics={detailedStatistics}
comparisonEnabled={comparisonEnabled}
offset={offset}
/>
);
}
export const osVersionTab = {
dataTestSubj: 'apmOsVersionTab',
key: 'os_version_tab',
label: i18n.translate(
'xpack.apm.mobile.transactions.overview.tabs.os.version',
{
defaultMessage: 'OS version',
}
),
component: OSVersionTab,
};

View file

@ -0,0 +1,148 @@
/*
* 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 { RIGHT_ALIGNMENT, EuiText } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { ValuesType } from 'utility-types';
import { APIReturnType } from '../../../../../../services/rest/create_call_apm_api';
import {
ChartType,
getTimeSeriesColor,
} from '../../../../../shared/charts/helper/get_timeseries_color';
import { SparkPlot } from '../../../../../shared/charts/spark_plot';
import { isTimeComparison } from '../../../../../shared/time_comparison/get_comparison_options';
import {
asMillisecondDuration,
asPercent,
asTransactionRate,
} from '../../../../../../../common/utils/formatters';
import { ITableColumn } from '../../../../../shared/managed_table';
type MobileMainStatisticsByField =
APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/main_statistics'>;
type MobileMainStatisticsByFieldItem = ValuesType<
MobileMainStatisticsByField['mainStatistics']
>;
type MobileDetailedStatisticsByField =
APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics'>;
export function getColumns({
agentName,
detailedStatisticsLoading,
detailedStatistics,
comparisonEnabled,
offset,
}: {
agentName?: string;
detailedStatisticsLoading: boolean;
detailedStatistics: MobileDetailedStatisticsByField;
comparisonEnabled?: boolean;
offset?: string;
}): Array<ITableColumn<MobileMainStatisticsByFieldItem>> {
return [
// version/device
{
field: 'name',
name: i18n.translate(
'xpack.apm.mobile.transactions.overview.table.nameColumnLabel',
{
defaultMessage: 'Name',
}
),
},
// latency
{
field: 'latency',
name: i18n.translate(
'xpack.apm.mobile.transactions.overview.table.latencyColumnAvgLabel',
{
defaultMessage: 'Latency (avg.)',
}
),
align: RIGHT_ALIGNMENT,
render: (_, { latency, name }) => {
const currentPeriodTimeseries =
detailedStatistics?.currentPeriod?.[name]?.latency;
const previousPeriodTimeseries =
detailedStatistics?.previousPeriod?.[name]?.latency;
const { currentPeriodColor, previousPeriodColor } = getTimeSeriesColor(
ChartType.LATENCY_AVG
);
return (
<SparkPlot
color={currentPeriodColor}
isLoading={detailedStatisticsLoading}
series={currentPeriodTimeseries}
valueLabel={asMillisecondDuration(latency)}
comparisonSeries={
comparisonEnabled && isTimeComparison(offset)
? previousPeriodTimeseries
: undefined
}
comparisonSeriesColor={previousPeriodColor}
/>
);
},
},
// throughput
{
field: 'throughput',
name: i18n.translate(
'xpack.apm.mobile.transactions.overview.table.throughputColumnAvgLabel',
{ defaultMessage: 'Throughput' }
),
align: RIGHT_ALIGNMENT,
render: (_, { throughput, name }) => {
const currentPeriodTimeseries =
detailedStatistics?.currentPeriod?.[name]?.throughput;
const previousPeriodTimeseries =
detailedStatistics?.previousPeriod?.[name]?.throughput;
const { currentPeriodColor, previousPeriodColor } = getTimeSeriesColor(
ChartType.THROUGHPUT
);
return (
<SparkPlot
color={currentPeriodColor}
isLoading={detailedStatisticsLoading}
series={currentPeriodTimeseries}
valueLabel={asTransactionRate(throughput)}
comparisonSeries={
comparisonEnabled && isTimeComparison(offset)
? previousPeriodTimeseries
: undefined
}
comparisonSeriesColor={previousPeriodColor}
/>
);
},
},
// crash rate
{
field: 'crashRate',
name: i18n.translate(
'xpack.apm.mobile.transactions.overview.table.crashRateColumnLabel',
{
defaultMessage: 'Crash rate',
}
),
align: RIGHT_ALIGNMENT,
render: (_, { crashRate }) => {
return (
<EuiText size="s" textAlign="right">
{asPercent(crashRate, 1)}
</EuiText>
);
},
},
];
}

View file

@ -0,0 +1,67 @@
/*
* 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, { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { ManagedTable } from '../../../../../shared/managed_table';
import { APIReturnType } from '../../../../../../services/rest/create_call_apm_api';
import { getColumns } from './get_columns';
type MobileMainStatisticsByField =
APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/main_statistics'>['mainStatistics'];
type MobileDetailedStatisticsByField =
APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics'>;
interface Props {
isLoading: boolean;
mainStatistics: MobileMainStatisticsByField;
detailedStatisticsLoading: boolean;
detailedStatistics: MobileDetailedStatisticsByField;
comparisonEnabled?: boolean;
offset?: string;
}
export function StatsList({
isLoading,
mainStatistics,
detailedStatisticsLoading,
detailedStatistics,
comparisonEnabled,
offset,
}: Props) {
const columns = useMemo(() => {
return getColumns({
detailedStatisticsLoading,
detailedStatistics,
comparisonEnabled,
offset,
});
}, [
detailedStatisticsLoading,
detailedStatistics,
comparisonEnabled,
offset,
]);
return (
<ManagedTable
noItemsMessage={
isLoading
? i18n.translate('xpack.apm.mobile.stats.table.loading', {
defaultMessage: 'Loading...',
})
: i18n.translate('xpack.apm.mobile.stats.table.noDataMessage', {
defaultMessage: 'No data found',
})
}
items={mainStatistics}
columns={columns}
sortItems={false}
initialPageSize={25}
isLoading={isLoading}
/>
);
}

View file

@ -0,0 +1,40 @@
/*
* 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 from 'react';
import { i18n } from '@kbn/i18n';
import { TabContentProps } from '.';
import { TransactionsTable } from '../../../../shared/transactions_table';
function TransactionsTab({ environment, kuery, start, end }: TabContentProps) {
return (
<TransactionsTable
hideTitle
hideViewTransactionsLink
numberOfTransactionsPerPage={25}
showMaxTransactionGroupsExceededWarning
environment={environment}
kuery={kuery}
start={start}
end={end}
saveTableOptionsToUrl
/>
);
}
export const transactionsTab = {
dataTestSubj: 'apmTransactionsTab',
key: 'transactions',
label: i18n.translate(
'xpack.apm.mobile.transactions.overview.tabs.transactions',
{
defaultMessage: 'Transactions',
}
),
component: TransactionsTab,
};

View file

@ -0,0 +1,120 @@
/*
* 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 { v4 as uuidv4 } from 'uuid';
import { useApmServiceContext } from '../../../../../context/apm_service/use_apm_service_context';
import { useFetcher } from '../../../../../hooks/use_fetcher';
import { isTimeComparison } from '../../../../shared/time_comparison/get_comparison_options';
const INITIAL_STATE_MAIN_STATISTICS = {
mainStatistics: [],
requestId: undefined,
totalItems: 0,
};
const INITIAL_STATE_DETAILED_STATISTICS = {
currentPeriod: {},
previousPeriod: {},
};
interface Props {
field: string;
environment: string;
start: string;
end: string;
kuery: string;
comparisonEnabled: boolean;
offset?: string;
}
export function useMobileStatisticsFetcher({
field,
environment,
start,
end,
kuery,
comparisonEnabled,
offset,
}: Props) {
const { serviceName } = useApmServiceContext();
const { data = INITIAL_STATE_MAIN_STATISTICS, status: mainStatisticsStatus } =
useFetcher(
(callApmApi) => {
if (start && end) {
return callApmApi(
'GET /internal/apm/mobile-services/{serviceName}/main_statistics',
{
params: {
path: { serviceName },
query: {
environment,
kuery,
start,
end,
field,
},
},
}
).then((response) => {
return {
// Everytime the main statistics is refetched, updates the requestId making the comparison API to be refetched.
requestId: uuidv4(),
mainStatistics: response.mainStatistics,
totalItems: response.mainStatistics.length,
};
});
}
},
[environment, start, end, kuery, serviceName, field]
);
const { mainStatistics, requestId, totalItems } = data;
const {
data: detailedStatistics = INITIAL_STATE_DETAILED_STATISTICS,
status: detailedStatisticsStatus,
} = useFetcher(
(callApmApi) => {
if (totalItems && start && end) {
return callApmApi(
'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics',
{
params: {
path: { serviceName },
query: {
environment,
kuery,
start,
end,
field,
fieldValues: JSON.stringify(
data?.mainStatistics.map(({ name }) => name).sort()
),
offset:
comparisonEnabled && isTimeComparison(offset)
? offset
: undefined,
},
},
}
);
}
},
// only fetches agg results when requestId changes
// eslint-disable-next-line react-hooks/exhaustive-deps
[requestId],
{ preservePreviousData: false }
);
return {
mainStatistics,
mainStatisticsStatus,
detailedStatistics,
detailedStatisticsStatus,
};
}

View file

@ -146,6 +146,7 @@ export const mobileServiceDetail = {
osVersion: t.string,
appVersion: t.string,
netConnectionType: t.string,
mobileSelectedTab: t.string,
}),
}),
children: {

View file

@ -65,6 +65,7 @@ const DEFAULT_SORT = {
};
interface Props {
hideTitle?: boolean;
hideViewTransactionsLink?: boolean;
isSingleColumn?: boolean;
numberOfTransactionsPerPage?: number;
@ -81,6 +82,7 @@ interface Props {
export function TransactionsTable({
fixedHeight = false,
hideViewTransactionsLink = false,
hideTitle = false,
isSingleColumn = true,
numberOfTransactionsPerPage = 5,
showPerPageOptions = true,
@ -294,32 +296,35 @@ export function TransactionsTable({
gutterSize="s"
data-test-subj="transactionsGroupTable"
>
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween" responsive={false}>
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.apm.transactionsTable.title', {
defaultMessage: 'Transactions',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
{!hideViewTransactionsLink && (
{!hideTitle && (
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween" responsive={false}>
<EuiFlexItem grow={false}>
<TransactionOverviewLink
serviceName={serviceName}
latencyAggregationType={latencyAggregationType}
transactionType={transactionType}
>
{i18n.translate('xpack.apm.transactionsTable.linkText', {
defaultMessage: 'View transactions',
})}
</TransactionOverviewLink>
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.apm.transactionsTable.title', {
defaultMessage: 'Transactions',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
{!hideViewTransactionsLink && (
<EuiFlexItem grow={false}>
<TransactionOverviewLink
serviceName={serviceName}
latencyAggregationType={latencyAggregationType}
transactionType={transactionType}
>
{i18n.translate('xpack.apm.transactionsTable.linkText', {
defaultMessage: 'View transactions',
})}
</TransactionOverviewLink>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
)}
{showMaxTransactionGroupsExceededWarning && maxTransactionGroupsExceeded && (
<EuiFlexItem>
<EuiCallOut

View file

@ -88,6 +88,10 @@ const documentTypeConfigMap: Record<
},
}),
},
[ApmDocumentType.ErrorEvent]: {
processorEvent: ProcessorEvent.error,
rollupIntervals: [RollupInterval.None],
},
};
type DocumentTypeConfigOf<TApmDocumentType extends ApmDocumentType> =

View file

@ -0,0 +1,214 @@
/*
* 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 {
kqlQuery,
rangeQuery,
termQuery,
} from '@kbn/observability-plugin/server';
import { keyBy } from 'lodash';
import { getBucketSize } from '../../../common/utils/get_bucket_size';
import { getOffsetInMs } from '../../../common/utils/get_offset_in_ms';
import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client';
import { environmentQuery } from '../../../common/utils/environment_query';
import {
SERVICE_NAME,
TRANSACTION_DURATION,
} from '../../../common/es_fields/apm';
import { getLatencyValue } from '../../lib/helpers/latency_aggregation_type';
import { LatencyAggregationType } from '../../../common/latency_aggregation_types';
import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_previous_period_coordinate';
import { Coordinate } from '../../../typings/timeseries';
import { ApmDocumentType } from '../../../common/document_type';
import { RollupInterval } from '../../../common/rollup';
interface MobileDetailedStatistics {
fieldName: string;
latency: Coordinate[];
throughput: Coordinate[];
}
export interface MobileDetailedStatisticsResponse {
currentPeriod: Record<string, MobileDetailedStatistics>;
previousPeriod: Record<string, MobileDetailedStatistics>;
}
interface Props {
kuery: string;
apmEventClient: APMEventClient;
serviceName: string;
environment: string;
start: number;
end: number;
field: string;
fieldValues: string[];
offset?: string;
}
async function getMobileDetailedStatisticsByField({
environment,
kuery,
serviceName,
field,
fieldValues,
apmEventClient,
start,
end,
offset,
}: Props) {
const { startWithOffset, endWithOffset } = getOffsetInMs({
start,
end,
offset,
});
const { intervalString } = getBucketSize({
start: startWithOffset,
end: endWithOffset,
minBucketSize: 60,
});
const response = await apmEventClient.search(
`get_mobile_detailed_statistics_by_field`,
{
apm: {
sources: [
{
documentType: ApmDocumentType.TransactionEvent,
rollupInterval: RollupInterval.None,
},
],
},
body: {
track_total_hits: false,
size: 0,
query: {
bool: {
filter: [
...termQuery(SERVICE_NAME, serviceName),
...rangeQuery(startWithOffset, endWithOffset),
...environmentQuery(environment),
...kqlQuery(kuery),
],
},
},
aggs: {
detailed_statistics: {
terms: {
field,
include: fieldValues,
size: fieldValues.length,
},
aggs: {
timeseries: {
date_histogram: {
field: '@timestamp',
fixed_interval: intervalString,
min_doc_count: 0,
extended_bounds: {
min: startWithOffset,
max: endWithOffset,
},
},
aggs: {
latency: {
avg: {
field: TRANSACTION_DURATION,
},
},
},
},
},
},
},
},
}
);
const buckets = response.aggregations?.detailed_statistics.buckets ?? [];
return buckets.map((bucket) => {
const fieldName = bucket.key as string;
const latency = bucket.timeseries.buckets.map((timeseriesBucket) => ({
x: timeseriesBucket.key,
y: getLatencyValue({
latencyAggregationType: LatencyAggregationType.avg,
aggregation: timeseriesBucket.latency,
}),
}));
const throughput = bucket.timeseries.buckets.map((timeseriesBucket) => ({
x: timeseriesBucket.key,
y: timeseriesBucket.doc_count,
}));
return {
fieldName,
latency,
throughput,
};
});
}
export async function getMobileDetailedStatisticsByFieldPeriods({
environment,
kuery,
serviceName,
field,
fieldValues,
apmEventClient,
start,
end,
offset,
}: Props): Promise<MobileDetailedStatisticsResponse> {
const commonProps = {
environment,
kuery,
serviceName,
field,
fieldValues,
apmEventClient,
start,
end,
};
const currentPeriodPromise = getMobileDetailedStatisticsByField({
...commonProps,
});
const previousPeriodPromise = offset
? getMobileDetailedStatisticsByField({
...commonProps,
offset,
})
: [];
const [currentPeriod, previousPeriod] = await Promise.all([
currentPeriodPromise,
previousPeriodPromise,
]);
const firstCurrentPeriod = currentPeriod?.[0];
return {
currentPeriod: keyBy(currentPeriod, 'fieldName'),
previousPeriod: keyBy(
previousPeriod.map((data) => {
return {
...data,
latency: offsetPreviousPeriodCoordinates({
currentPeriodTimeseries: firstCurrentPeriod?.latency,
previousPeriodTimeseries: data.latency,
}),
throughput: offsetPreviousPeriodCoordinates({
currentPeriodTimeseries: firstCurrentPeriod?.throughput,
previousPeriodTimeseries: data.throughput,
}),
};
}),
'fieldName'
),
};
}

View file

@ -0,0 +1,186 @@
/*
* 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 { merge } from 'lodash';
import {
SERVICE_NAME,
SESSION_ID,
TRANSACTION_DURATION,
ERROR_TYPE,
} 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 { getLatencyValue } from '../../lib/helpers/latency_aggregation_type';
import { LatencyAggregationType } from '../../../common/latency_aggregation_types';
import { calculateThroughputWithRange } from '../../lib/helpers/calculate_throughput';
import { ApmDocumentType } from '../../../common/document_type';
import { RollupInterval } from '../../../common/rollup';
interface Props {
kuery: string;
apmEventClient: APMEventClient;
serviceName: string;
environment: string;
start: number;
end: number;
field: string;
}
export interface MobileMainStatisticsResponse {
mainStatistics: Array<{
name: string | number;
latency: number | null;
throughput: number;
crashRate?: number;
}>;
}
export async function getMobileMainStatisticsByField({
kuery,
apmEventClient,
serviceName,
environment,
start,
end,
field,
}: Props) {
async function getMobileTransactionEventStatistics() {
const response = await apmEventClient.search(
`get_mobile_main_statistics_by_field`,
{
apm: {
sources: [
{
documentType: ApmDocumentType.TransactionEvent,
rollupInterval: RollupInterval.None,
},
],
},
body: {
track_total_hits: false,
size: 0,
query: {
bool: {
filter: [
...termQuery(SERVICE_NAME, serviceName),
...rangeQuery(start, end),
...environmentQuery(environment),
...kqlQuery(kuery),
],
},
},
aggs: {
main_statistics: {
terms: {
field,
size: 1000,
},
aggs: {
latency: {
avg: {
field: TRANSACTION_DURATION,
},
},
},
},
},
},
}
);
return (
response.aggregations?.main_statistics.buckets.map((bucket) => {
return {
name: bucket.key,
latency: getLatencyValue({
latencyAggregationType: LatencyAggregationType.avg,
aggregation: bucket.latency,
}),
throughput: calculateThroughputWithRange({
start,
end,
value: bucket.doc_count,
}),
};
}) ?? []
);
}
async function getMobileErrorEventStatistics() {
const response = await apmEventClient.search(
`get_mobile_transaction_events_main_statistics_by_field`,
{
apm: {
sources: [
{
documentType: ApmDocumentType.ErrorEvent,
rollupInterval: RollupInterval.None,
},
],
},
body: {
track_total_hits: false,
size: 0,
query: {
bool: {
filter: [
...termQuery(SERVICE_NAME, serviceName),
...rangeQuery(start, end),
...environmentQuery(environment),
...kqlQuery(kuery),
],
},
},
aggs: {
main_statistics: {
terms: {
field,
size: 1000,
},
aggs: {
sessions: {
cardinality: {
field: SESSION_ID,
},
},
crashes: {
filter: {
term: {
[ERROR_TYPE]: 'crash',
},
},
},
},
},
},
},
}
);
return (
response.aggregations?.main_statistics.buckets.map((bucket) => {
return {
name: bucket.key,
crashRate: bucket.crashes.doc_count / bucket.sessions.value ?? 0,
};
}) ?? []
);
}
const [transactioEventStatistics, errorEventStatistics] = await Promise.all([
getMobileTransactionEventStatistics(),
getMobileErrorEventStatistics(),
]);
const mainStatistics = merge(transactioEventStatistics, errorEventStatistics);
return { mainStatistics };
}

View file

@ -6,7 +6,7 @@
*/
import * as t from 'io-ts';
import { toNumberRt } from '@kbn/io-ts-utils';
import { jsonRt, toNumberRt } from '@kbn/io-ts-utils';
import { getApmEventClient } from '../../lib/helpers/get_apm_event_client';
import { createApmServerRoute } from '../apm_routes/create_apm_server_route';
import { environmentRt, kueryRt, rangeRt } from '../default_api_types';
@ -26,6 +26,14 @@ import {
getMobileTermsByField,
MobileTermsByFieldResponse,
} from './get_mobile_terms_by_field';
import {
getMobileMainStatisticsByField,
MobileMainStatisticsResponse,
} from './get_mobile_main_statistics_by_field';
import {
getMobileDetailedStatisticsByFieldPeriods,
MobileDetailedStatisticsResponse,
} from './get_mobile_detailed_statistics_by_field';
import {
getMobileMostUsedCharts,
MobileMostUsedChartResponse,
@ -329,6 +337,84 @@ const mobileTermsByFieldRoute = createApmServerRoute({
},
});
const mobileMainStatisticsByField = createApmServerRoute({
endpoint: 'GET /internal/apm/mobile-services/{serviceName}/main_statistics',
params: t.type({
path: t.type({
serviceName: t.string,
}),
query: t.intersection([
kueryRt,
rangeRt,
environmentRt,
t.type({
field: t.string,
}),
]),
}),
options: {
tags: ['access:apm'],
},
handler: async (resources): Promise<MobileMainStatisticsResponse> => {
const apmEventClient = await getApmEventClient(resources);
const { params } = resources;
const { serviceName } = params.path;
const { kuery, environment, start, end, field } = params.query;
return await getMobileMainStatisticsByField({
kuery,
environment,
start,
end,
serviceName,
apmEventClient,
field,
});
},
});
const mobileDetailedStatisticsByField = createApmServerRoute({
endpoint:
'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics',
params: t.type({
path: t.type({
serviceName: t.string,
}),
query: t.intersection([
kueryRt,
rangeRt,
offsetRt,
environmentRt,
t.type({
field: t.string,
fieldValues: jsonRt.pipe(t.array(t.string)),
}),
]),
}),
options: {
tags: ['access:apm'],
},
handler: async (resources): Promise<MobileDetailedStatisticsResponse> => {
const apmEventClient = await getApmEventClient(resources);
const { params } = resources;
const { serviceName } = params.path;
const { kuery, environment, start, end, field, offset, fieldValues } =
params.query;
return await getMobileDetailedStatisticsByFieldPeriods({
kuery,
environment,
start,
end,
serviceName,
apmEventClient,
field,
fieldValues,
offset,
});
},
});
export const mobileRouteRepository = {
...mobileFiltersRoute,
...mobileChartsRoute,
@ -337,4 +423,6 @@ export const mobileRouteRepository = {
...mobileStatsRoute,
...mobileLocationStatsRoute,
...mobileTermsByFieldRoute,
...mobileMainStatisticsByField,
...mobileDetailedStatisticsByField,
};

View file

@ -7,6 +7,8 @@
import { apm, timerange } from '@kbn/apm-synthtrace-client';
import { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace';
export const SERVICE_VERSIONS = ['2.3', '1.2', '1.1'];
export async function generateMobileData({
start,
end,
@ -22,7 +24,7 @@ export async function generateMobileData({
environment: 'production',
agentName: 'android/java',
})
.mobileDevice({ serviceVersion: '2.3' })
.mobileDevice({ serviceVersion: SERVICE_VERSIONS[0] })
.deviceInfo({
manufacturer: 'Samsung',
modelIdentifier: 'SM-G973F',
@ -52,7 +54,7 @@ export async function generateMobileData({
environment: 'production',
agentName: 'android/java',
})
.mobileDevice({ serviceVersion: '1.2' })
.mobileDevice({ serviceVersion: SERVICE_VERSIONS[1] })
.deviceInfo({
manufacturer: 'Samsung',
modelIdentifier: 'SM-G930F',
@ -89,7 +91,7 @@ export async function generateMobileData({
environment: 'production',
agentName: 'android/java',
})
.mobileDevice({ serviceVersion: '1.1' })
.mobileDevice({ serviceVersion: SERVICE_VERSIONS[2] })
.deviceInfo({
manufacturer: 'Huawei',
modelIdentifier: 'HUAWEI P2-0000',
@ -222,6 +224,7 @@ export async function generateMobileData({
return [
galaxy10
.transaction('Start View - View Appearing', 'Android Activity')
.errors(galaxy10.crash({ message: 'error' }).timestamp(timestamp))
.timestamp(timestamp)
.duration(500)
.success()
@ -265,6 +268,7 @@ export async function generateMobileData({
),
huaweiP2
.transaction('Start View - View Appearing', 'huaweiP2 Activity')
.errors(huaweiP2.crash({ message: 'error' }).timestamp(timestamp))
.timestamp(timestamp)
.duration(20)
.success()
@ -292,6 +296,7 @@ export async function generateMobileData({
),
galaxy7
.transaction('Start View - View Appearing', 'Android Activity')
.errors(galaxy7.crash({ message: 'error' }).timestamp(timestamp))
.timestamp(timestamp)
.duration(20)
.success()

View file

@ -0,0 +1,132 @@
/*
* 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 { ENVIRONMENT_ALL } from '@kbn/apm-plugin/common/environment_filter_values';
import { isEmpty } from 'lodash';
import moment from 'moment';
import { APIReturnType } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { generateMobileData, SERVICE_VERSIONS } from './generate_mobile_data';
type MobileDetailedStatisticsResponse =
APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics'>;
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 getMobileDetailedStatisticsByField({
environment = ENVIRONMENT_ALL.value,
kuery = '',
serviceName,
field,
offset,
}: {
environment?: string;
kuery?: string;
serviceName: string;
field: string;
offset?: string;
}) {
return await apmApiClient
.readUser({
endpoint: 'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics',
params: {
path: { serviceName },
query: {
environment,
start: moment(end).subtract(7, 'minutes').toISOString(),
end: new Date(end).toISOString(),
offset,
kuery,
field,
fieldValues: JSON.stringify(SERVICE_VERSIONS),
},
},
})
.then(({ body }) => body);
}
registry.when(
'Mobile detailed statistics when data is not loaded',
{ config: 'basic', archives: [] },
() => {
describe('when no data', () => {
it('handles empty state', async () => {
const response = await getMobileDetailedStatisticsByField({
serviceName: 'foo',
field: 'service.version',
});
expect(response).to.be.eql({ currentPeriod: {}, previousPeriod: {} });
});
});
}
);
registry.when(
'Mobile detailed statistics when data is loaded',
{ config: 'basic', archives: [] },
() => {
before(async () => {
await generateMobileData({
synthtraceEsClient,
start,
end,
});
});
after(() => synthtraceEsClient.clean());
describe('when comparison is disable', () => {
it('returns current period data only', async () => {
const response = await getMobileDetailedStatisticsByField({
serviceName: 'synth-android',
environment: 'production',
field: 'service.version',
});
expect(isEmpty(response.currentPeriod)).to.be.equal(false);
expect(isEmpty(response.previousPeriod)).to.be.equal(true);
});
});
describe('when comparison is enable', () => {
let mobiledetailedStatisticResponse: MobileDetailedStatisticsResponse;
before(async () => {
mobiledetailedStatisticResponse = await getMobileDetailedStatisticsByField({
serviceName: 'synth-android',
environment: 'production',
field: 'service.version',
offset: '8m',
});
});
it('returns some data for both periods', async () => {
expect(isEmpty(mobiledetailedStatisticResponse.currentPeriod)).to.be.equal(false);
expect(isEmpty(mobiledetailedStatisticResponse.previousPeriod)).to.be.equal(false);
});
it('returns same number of buckets for both periods', () => {
const currentPeriod = mobiledetailedStatisticResponse.currentPeriod[SERVICE_VERSIONS[0]];
const previousPeriod =
mobiledetailedStatisticResponse.previousPeriod[SERVICE_VERSIONS[0]];
[
[currentPeriod.latency, previousPeriod.latency],
[currentPeriod.throughput, previousPeriod.throughput],
].forEach(([currentTimeseries, previousTimeseries]) => {
expect(currentTimeseries.length).to.equal(previousTimeseries.length);
});
});
});
}
);
}

View file

@ -0,0 +1,143 @@
/*
* 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 { ENVIRONMENT_ALL } from '@kbn/apm-plugin/common/environment_filter_values';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { generateMobileData } from './generate_mobile_data';
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 getMobileMainStatisticsByField({
environment = ENVIRONMENT_ALL.value,
kuery = '',
serviceName,
field,
}: {
environment?: string;
kuery?: string;
serviceName: string;
field: string;
}) {
return await apmApiClient
.readUser({
endpoint: 'GET /internal/apm/mobile-services/{serviceName}/main_statistics',
params: {
path: { serviceName },
query: {
environment,
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
kuery,
field,
},
},
})
.then(({ body }) => body);
}
registry.when(
'Mobile main statistics when data is not loaded',
{ config: 'basic', archives: [] },
() => {
describe('when no data', () => {
it('handles empty state', async () => {
const response = await getMobileMainStatisticsByField({
serviceName: 'foo',
field: 'service.version',
});
expect(response.mainStatistics.length).to.be(0);
});
});
}
);
registry.when('Mobile main statistics', { config: 'basic', archives: [] }, () => {
before(async () => {
await generateMobileData({
synthtraceEsClient,
start,
end,
});
});
after(() => synthtraceEsClient.clean());
describe('when data is loaded', () => {
it('returns the correct data for App version', async () => {
const response = await getMobileMainStatisticsByField({
serviceName: 'synth-android',
environment: 'production',
field: 'service.version',
});
const fieldValues = response.mainStatistics.map((item) => item.name);
expect(fieldValues).to.be.eql(['1.1', '1.2', '2.3']);
const latencyValues = response.mainStatistics.map((item) => item.latency);
expect(latencyValues).to.be.eql([172000, 20000, 20000]);
const throughputValues = response.mainStatistics.map((item) => item.throughput);
expect(throughputValues).to.be.eql([
1.0000011111123457, 0.20000022222246913, 0.20000022222246913,
]);
});
it('returns the correct data for Os version', async () => {
const response = await getMobileMainStatisticsByField({
serviceName: 'synth-android',
environment: 'production',
field: 'host.os.version',
});
const fieldValues = response.mainStatistics.map((item) => item.name);
expect(fieldValues).to.be.eql(['10']);
const latencyValues = response.mainStatistics.map((item) => item.latency);
expect(latencyValues).to.be.eql([128571.42857142857]);
const throughputValues = response.mainStatistics.map((item) => item.throughput);
expect(throughputValues).to.be.eql([1.4000015555572838]);
});
it('returns the correct data for Devices', async () => {
const response = await getMobileMainStatisticsByField({
serviceName: 'synth-android',
environment: 'production',
field: 'device.model.identifier',
});
const fieldValues = response.mainStatistics.map((item) => item.name);
expect(fieldValues).to.be.eql([
'HUAWEI P2-0000',
'SM-G930F',
'SM-G973F',
'Pixel 7 Pro',
'Pixel 8',
'SM-G930F',
]);
const latencyValues = response.mainStatistics.map((item) => item.latency);
expect(latencyValues).to.be.eql([400000, 20000, 20000, 20000, 20000, 20000]);
const throughputValues = response.mainStatistics.map((item) => item.throughput);
expect(throughputValues).to.be.eql([
0.40000044444493826, 0.20000022222246913, 0.20000022222246913, 0.20000022222246913,
0.20000022222246913, 0.20000022222246913,
]);
});
});
});
}