From b6a91f318ecc8398a166803fcef039b7e8424c1c Mon Sep 17 00:00:00 2001 From: Miriam <31922082+MiriamAparicio@users.noreply.github.com> Date: Tue, 25 Apr 2023 15:43:21 +0100 Subject: [PATCH] [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 --- .../src/lib/apm/apm_fields.ts | 2 + .../src/lib/apm/mobile_device.ts | 22 +- .../src/scenarios/mobile.ts | 88 ++++--- x-pack/plugins/apm/common/data_source.ts | 3 +- x-pack/plugins/apm/common/document_type.ts | 1 + .../__snapshots__/es_fields.test.ts.snap | 6 + x-pack/plugins/apm/common/es_fields/apm.ts | 1 + .../read_only_user/mobile/generate_data.ts | 221 ++++++++++++++++++ .../mobile/mobile_transactions.cy.ts | 68 ++++++ .../app/mobile/transaction_overview/index.tsx | 12 +- .../app_version_tab.tsx | 61 +++++ .../transaction_overview_tabs/devices_tab.tsx | 58 +++++ .../transaction_overview_tabs/index.tsx | 77 ++++++ .../os_version_tab.tsx | 61 +++++ .../stats_list/get_columns.tsx | 148 ++++++++++++ .../stats_list/index.tsx | 67 ++++++ .../transactions_tab.tsx | 40 ++++ .../use_mobile_statistics_fetcher.ts | 120 ++++++++++ .../routing/mobile_service_detail/index.tsx | 1 + .../shared/transactions_table/index.tsx | 53 +++-- .../helpers/create_es_client/document_type.ts | 4 + ...get_mobile_detailed_statistics_by_field.ts | 214 +++++++++++++++++ .../get_mobile_main_statistics_by_field.ts | 186 +++++++++++++++ .../plugins/apm/server/routes/mobile/route.ts | 90 ++++++- .../tests/mobile/generate_mobile_data.ts | 11 +- ...obile_detailed_statistics_by_field.spec.ts | 132 +++++++++++ .../mobile_main_statistics_by_field.spec.ts | 143 ++++++++++++ 27 files changed, 1826 insertions(+), 64 deletions(-) create mode 100644 x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/generate_data.ts create mode 100644 x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/mobile_transactions.cy.ts create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/app_version_tab.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/devices_tab.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/index.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/os_version_tab.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/get_columns.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/index.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/transactions_tab.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/use_mobile_statistics_fetcher.ts create mode 100644 x-pack/plugins/apm/server/routes/mobile/get_mobile_detailed_statistics_by_field.ts create mode 100644 x-pack/plugins/apm/server/routes/mobile/get_mobile_main_statistics_by_field.ts create mode 100644 x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts diff --git a/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts b/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts index 808894dab55ef..d5efb1dbc2c97 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts @@ -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; diff --git a/packages/kbn-apm-synthtrace-client/src/lib/apm/mobile_device.ts b/packages/kbn-apm-synthtrace-client/src/lib/apm/mobile_device.ts index eddb7d6c99d18..252590104e7a2 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/apm/mobile_device.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/apm/mobile_device.ts @@ -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 { 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 { return this.span(spanParameters); } + + appMetrics(metrics: ApmApplicationMetricFields) { + return new Metricset({ + ...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, + }); + } } diff --git a/packages/kbn-apm-synthtrace/src/scenarios/mobile.ts b/packages/kbn-apm-synthtrace/src/scenarios/mobile.ts index 6db2d17b624f9..0ca4abf07bf91 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/mobile.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/mobile.ts @@ -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 = 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 = 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)), + ]; }, }; }; diff --git a/x-pack/plugins/apm/common/data_source.ts b/x-pack/plugins/apm/common/data_source.ts index b951677a8cb65..9282fb372ac72 100644 --- a/x-pack/plugins/apm/common/data_source.ts +++ b/x-pack/plugins/apm/common/data_source.ts @@ -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 diff --git a/x-pack/plugins/apm/common/document_type.ts b/x-pack/plugins/apm/common/document_type.ts index 333b9f69e0d0f..92a17c3125a96 100644 --- a/x-pack/plugins/apm/common/document_type.ts +++ b/x-pack/plugins/apm/common/document_type.ts @@ -11,6 +11,7 @@ export enum ApmDocumentType { TransactionEvent = 'transactionEvent', ServiceDestinationMetric = 'serviceDestinationMetric', ServiceSummaryMetric = 'serviceSummaryMetric', + ErrorEvent = 'error', } export type ApmServiceTransactionDocumentType = diff --git a/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap b/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap index 3ddc94bde0255..9dfb15ed9cb05 100644 --- a/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap +++ b/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap @@ -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"`; diff --git a/x-pack/plugins/apm/common/es_fields/apm.ts b/x-pack/plugins/apm/common/es_fields/apm.ts index 9a1dd15f94a75..141be32365956 100644 --- a/x-pack/plugins/apm/common/es_fields/apm.ts +++ b/x-pack/plugins/apm/common/es_fields/apm.ts @@ -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'; diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/generate_data.ts b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/generate_data.ts new file mode 100644 index 0000000000000..5b12bd58b76be --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/generate_data.ts @@ -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) + ), + ]; + }); +} diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/mobile_transactions.cy.ts b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/mobile_transactions.cy.ts new file mode 100644 index 0000000000000..85cf055507f3b --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/mobile_transactions.cy.ts @@ -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'); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/index.tsx index be6963994c639..ce06e04683af9 100644 --- a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/index.tsx @@ -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() { /> - diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/app_version_tab.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/app_version_tab.tsx new file mode 100644 index 0000000000000..deafdeb59d3c5 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/app_version_tab.tsx @@ -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 ( + + ); +} + +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, +}; diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/devices_tab.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/devices_tab.tsx new file mode 100644 index 0000000000000..4d2f18b046709 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/devices_tab.tsx @@ -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 ( + + ); +} + +export const devicesTab = { + dataTestSubj: 'apmDevicesTab', + key: 'devices_tab', + label: i18n.translate('xpack.apm.mobile.transactions.overview.tabs.devices', { + defaultMessage: 'Devices', + }), + component: DevicesTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/index.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/index.tsx new file mode 100644 index 0000000000000..c986f5903b7b5 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/index.tsx @@ -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 ( + <> + + {tabs.map(({ dataTestSubj, key, label }) => ( + { + push(history, { + query: { + mobileSelectedTab: key, + }, + }); + }} + > + {label} + + ))} + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/os_version_tab.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/os_version_tab.tsx new file mode 100644 index 0000000000000..6eee1f01aae9f --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/os_version_tab.tsx @@ -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 ( + + ); +} + +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, +}; diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/get_columns.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/get_columns.tsx new file mode 100644 index 0000000000000..18ac252011357 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/get_columns.tsx @@ -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> { + 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 ( + + ); + }, + }, + // 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 ( + + ); + }, + }, + // crash rate + { + field: 'crashRate', + name: i18n.translate( + 'xpack.apm.mobile.transactions.overview.table.crashRateColumnLabel', + { + defaultMessage: 'Crash rate', + } + ), + align: RIGHT_ALIGNMENT, + render: (_, { crashRate }) => { + return ( + + {asPercent(crashRate, 1)} + + ); + }, + }, + ]; +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/index.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/index.tsx new file mode 100644 index 0000000000000..ab71f49421ddd --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/index.tsx @@ -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 ( + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/transactions_tab.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/transactions_tab.tsx new file mode 100644 index 0000000000000..4fef8262e6305 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/transactions_tab.tsx @@ -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 ( + + ); +} + +export const transactionsTab = { + dataTestSubj: 'apmTransactionsTab', + key: 'transactions', + label: i18n.translate( + 'xpack.apm.mobile.transactions.overview.tabs.transactions', + { + defaultMessage: 'Transactions', + } + ), + component: TransactionsTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/use_mobile_statistics_fetcher.ts b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/use_mobile_statistics_fetcher.ts new file mode 100644 index 0000000000000..4c3bd48e5e089 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/use_mobile_statistics_fetcher.ts @@ -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, + }; +} diff --git a/x-pack/plugins/apm/public/components/routing/mobile_service_detail/index.tsx b/x-pack/plugins/apm/public/components/routing/mobile_service_detail/index.tsx index 52e127c63f805..7b8e4a120c298 100644 --- a/x-pack/plugins/apm/public/components/routing/mobile_service_detail/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/mobile_service_detail/index.tsx @@ -146,6 +146,7 @@ export const mobileServiceDetail = { osVersion: t.string, appVersion: t.string, netConnectionType: t.string, + mobileSelectedTab: t.string, }), }), children: { diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx index 42d03902bc313..c4df8a4f7e9a9 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx @@ -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" > - - - - -

- {i18n.translate('xpack.apm.transactionsTable.title', { - defaultMessage: 'Transactions', - })} -

-
-
- {!hideViewTransactionsLink && ( + {!hideTitle && ( + + - - {i18n.translate('xpack.apm.transactionsTable.linkText', { - defaultMessage: 'View transactions', - })} - + +

+ {i18n.translate('xpack.apm.transactionsTable.title', { + defaultMessage: 'Transactions', + })} +

+
- )} -
-
+ {!hideViewTransactionsLink && ( + + + {i18n.translate('xpack.apm.transactionsTable.linkText', { + defaultMessage: 'View transactions', + })} + + + )} +
+
+ )} + {showMaxTransactionGroupsExceededWarning && maxTransactionGroupsExceeded && ( = diff --git a/x-pack/plugins/apm/server/routes/mobile/get_mobile_detailed_statistics_by_field.ts b/x-pack/plugins/apm/server/routes/mobile/get_mobile_detailed_statistics_by_field.ts new file mode 100644 index 0000000000000..d511b22b13274 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/mobile/get_mobile_detailed_statistics_by_field.ts @@ -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; + previousPeriod: Record; +} + +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 { + 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' + ), + }; +} diff --git a/x-pack/plugins/apm/server/routes/mobile/get_mobile_main_statistics_by_field.ts b/x-pack/plugins/apm/server/routes/mobile/get_mobile_main_statistics_by_field.ts new file mode 100644 index 0000000000000..a5783997e391b --- /dev/null +++ b/x-pack/plugins/apm/server/routes/mobile/get_mobile_main_statistics_by_field.ts @@ -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 }; +} diff --git a/x-pack/plugins/apm/server/routes/mobile/route.ts b/x-pack/plugins/apm/server/routes/mobile/route.ts index 3f6de9de1b696..3323172a5e6d5 100644 --- a/x-pack/plugins/apm/server/routes/mobile/route.ts +++ b/x-pack/plugins/apm/server/routes/mobile/route.ts @@ -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 => { + 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 => { + 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, }; diff --git a/x-pack/test/apm_api_integration/tests/mobile/generate_mobile_data.ts b/x-pack/test/apm_api_integration/tests/mobile/generate_mobile_data.ts index cd3b4ed636272..91a8aac9bc3d3 100644 --- a/x-pack/test/apm_api_integration/tests/mobile/generate_mobile_data.ts +++ b/x-pack/test/apm_api_integration/tests/mobile/generate_mobile_data.ts @@ -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() diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts new file mode 100644 index 0000000000000..601e3b81e6dad --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts @@ -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); + }); + }); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts new file mode 100644 index 0000000000000..a58f6e58b99e6 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts @@ -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, + ]); + }); + }); + }); +}