Mobile UI crash widget (#163527)

## Summary

Implemented crash widget & most crashes by location widget in the mobile landing page in APM.


### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Bryce Buchanan 2023-09-18 10:56:00 -07:00 committed by GitHub
parent 707fbf115a
commit 70a2f4cdb5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 354 additions and 24 deletions

View file

@ -254,6 +254,7 @@ export class MobileDevice extends Entity<ApmFields> {
return new ApmError({
...this.fields,
'error.type': 'crash',
'error.id': generateLongId(message),
'error.exception': [{ message, ...{ type: 'crash' } }],
'error.grouping_name': groupingName || message,
});

View file

@ -133,17 +133,18 @@ export function MobileLocationStats({
trendShape: MetricTrendShape.Area,
},
{
color: euiTheme.eui.euiColorDisabled,
color: euiTheme.eui.euiColorLightestShade,
title: i18n.translate('xpack.apm.mobile.location.metrics.crashes', {
defaultMessage: 'Most crashes',
}),
subtitle: i18n.translate('xpack.apm.mobile.coming.soon', {
defaultMessage: 'Coming Soon',
extra: getComparisonValueFormatter({
currentPeriodValue: currentPeriod?.mostCrashes.value,
previousPeriodValue: previousPeriod?.mostCrashes.value,
}),
icon: getIcon('bug'),
value: NOT_AVAILABLE_LABEL,
value: currentPeriod?.mostCrashes.location ?? NOT_AVAILABLE_LABEL,
valueFormatter: (value) => `${value}`,
trend: [],
trend: currentPeriod?.mostCrashes.timeseries,
trendShape: MetricTrendShape.Area,
},
{

View file

@ -14,6 +14,7 @@ import {
} from '@elastic/eui';
import React, { useCallback } from 'react';
import { useTheme } from '@kbn/observability-shared-plugin/public';
import { NOT_AVAILABLE_LABEL } from '../../../../../../common/i18n';
import { useAnyOfApmParams } from '../../../../../hooks/use_apm_params';
import {
useFetcher,
@ -104,17 +105,16 @@ export function MobileStats({
const metrics: MetricDatum[] = [
{
color: euiTheme.eui.euiColorDisabled,
color: euiTheme.eui.euiColorLightestShade,
title: i18n.translate('xpack.apm.mobile.metrics.crash.rate', {
defaultMessage: 'Crash Rate (Crash per minute)',
}),
subtitle: i18n.translate('xpack.apm.mobile.coming.soon', {
defaultMessage: 'Coming Soon',
defaultMessage: 'Crash rate',
}),
icon: getIcon('bug'),
value: 'N/A',
valueFormatter: (value: number) => valueFormatter(value),
trend: [],
value: data?.currentPeriod?.crashRate?.value ?? NOT_AVAILABLE_LABEL,
valueFormatter: (value: number) =>
valueFormatter(Number((value * 100).toPrecision(2)), '%'),
trend: data?.currentPeriod?.crashRate?.timeseries,
extra: getComparisonValueFormatter(data?.previousPeriod.crashRate?.value),
trendShape: MetricTrendShape.Area,
},
{
@ -137,7 +137,7 @@ export function MobileStats({
defaultMessage: 'Sessions',
}),
icon: getIcon('timeslider'),
value: data?.currentPeriod?.sessions?.value ?? NaN,
value: data?.currentPeriod?.sessions?.value ?? NOT_AVAILABLE_LABEL,
valueFormatter: (value: number) => valueFormatter(value),
trend: data?.currentPeriod?.sessions?.timeseries,
extra: getComparisonValueFormatter(data?.previousPeriod.sessions?.value),
@ -149,7 +149,7 @@ export function MobileStats({
defaultMessage: 'HTTP requests',
}),
icon: getIcon('kubernetesPod'),
value: data?.currentPeriod?.requests?.value ?? NaN,
value: data?.currentPeriod?.requests?.value ?? NOT_AVAILABLE_LABEL,
extra: getComparisonValueFormatter(data?.previousPeriod.requests?.value),
valueFormatter: (value: number) => valueFormatter(value),
trend: data?.currentPeriod?.requests?.timeseries,

View file

@ -0,0 +1,165 @@
/*
* 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 { ProcessorEvent } from '@kbn/observability-plugin/common';
import {
kqlQuery,
rangeQuery,
termQuery,
} from '@kbn/observability-plugin/server';
import { Coordinate } from '../../../typings/timeseries';
import { Maybe } from '../../../typings/common';
import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client';
import { getBucketSize } from '../../../common/utils/get_bucket_size';
import {
ERROR_TYPE,
ERROR_ID,
SERVICE_NAME,
} from '../../../common/es_fields/apm';
import { environmentQuery } from '../../../common/utils/environment_query';
import { getOffsetInMs } from '../../../common/utils/get_offset_in_ms';
import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_previous_period_coordinate';
export interface CrashRateTimeseries {
currentPeriod: { timeseries: Coordinate[]; value: Maybe<number> };
previousPeriod: { timeseries: Coordinate[]; value: Maybe<number> };
}
interface Props {
apmEventClient: APMEventClient;
serviceName: string;
transactionName?: string;
environment: string;
start: number;
end: number;
kuery: string;
offset?: string;
}
async function getMobileCrashTimeseries({
apmEventClient,
serviceName,
transactionName,
environment,
start,
end,
kuery,
offset,
}: Props) {
const { startWithOffset, endWithOffset } = getOffsetInMs({
start,
end,
offset,
});
const { intervalString } = getBucketSize({
start: startWithOffset,
end: endWithOffset,
minBucketSize: 60,
});
const aggs = {
crashes: {
cardinality: { field: ERROR_ID },
},
};
const response = await apmEventClient.search('get_mobile_crash_rate', {
apm: {
events: [ProcessorEvent.error],
},
body: {
track_total_hits: false,
size: 0,
query: {
bool: {
filter: [
...termQuery(ERROR_TYPE, 'crash'),
...termQuery(SERVICE_NAME, serviceName),
...rangeQuery(startWithOffset, endWithOffset),
...environmentQuery(environment),
...kqlQuery(kuery),
],
},
},
aggs: {
timeseries: {
date_histogram: {
field: '@timestamp',
fixed_interval: intervalString,
min_doc_count: 0,
extended_bounds: { min: startWithOffset, max: endWithOffset },
},
aggs,
},
...aggs,
},
},
});
const timeseries =
response?.aggregations?.timeseries.buckets.map((bucket) => {
return {
x: bucket.key,
y: bucket.crashes.value,
};
}) ?? [];
return {
timeseries,
value: response.aggregations?.crashes?.value,
};
}
export async function getMobileCrashRate({
kuery,
apmEventClient,
serviceName,
transactionName,
environment,
start,
end,
offset,
}: Props): Promise<CrashRateTimeseries> {
const options = {
serviceName,
transactionName,
apmEventClient,
kuery,
environment,
};
const currentPeriodPromise = getMobileCrashTimeseries({
...options,
start,
end,
});
const previousPeriodPromise = offset
? getMobileCrashTimeseries({
...options,
start,
end,
offset,
})
: { timeseries: [], value: null };
const [currentPeriod, previousPeriod] = await Promise.all([
currentPeriodPromise,
previousPeriodPromise,
]);
return {
currentPeriod,
previousPeriod: {
timeseries: offsetPreviousPeriodCoordinates({
currentPeriodTimeseries: currentPeriod.timeseries,
previousPeriodTimeseries: previousPeriod.timeseries,
}),
value: previousPeriod?.value,
},
};
}

View file

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

View file

@ -9,6 +9,7 @@ import { CLIENT_GEO_COUNTRY_NAME } from '../../../common/es_fields/apm';
import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client';
import { getSessionsByLocation } from './get_mobile_sessions_by_location';
import { getHttpRequestsByLocation } from './get_mobile_http_requests_by_location';
import { getCrashesByLocation } from './get_mobile_crashes_by_location';
import { Maybe } from '../../../typings/common';
export type Timeseries = Array<{ x: number; y: number }>;
@ -24,6 +25,11 @@ interface LocationStats {
value: Maybe<number>;
timeseries: Timeseries;
};
mostCrashes: {
location?: string;
value: Maybe<number>;
timeseries: Timeseries;
};
}
export interface MobileLocationStats {
@ -63,14 +69,16 @@ async function getMobileLocationStats({
offset,
};
const [mostSessions, mostRequests] = await Promise.all([
const [mostSessions, mostRequests, mostCrashes] = await Promise.all([
getSessionsByLocation({ ...commonProps }),
getHttpRequestsByLocation({ ...commonProps }),
getCrashesByLocation({ ...commonProps }),
]);
return {
mostSessions,
mostRequests,
mostCrashes,
};
}
@ -108,6 +116,7 @@ export async function getMobileLocationStatsPeriods({
: {
mostSessions: { value: null, timeseries: [] },
mostRequests: { value: null, timeseries: [] },
mostCrashes: { value: null, timeseries: [] },
};
const [currentPeriod, previousPeriod] = await Promise.all([

View file

@ -9,6 +9,7 @@ import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_ev
import { getOffsetInMs } from '../../../common/utils/get_offset_in_ms';
import { getMobileSessions } from './get_mobile_sessions';
import { getMobileHttpRequests } from './get_mobile_http_requests';
import { getMobileCrashRate } from './get_mobile_crash_rate';
import { Maybe } from '../../../typings/common';
export interface Timeseries {
@ -18,6 +19,7 @@ export interface Timeseries {
interface MobileStats {
sessions: { timeseries: Timeseries[]; value: Maybe<number> };
requests: { timeseries: Timeseries[]; value: Maybe<number> };
crashRate: { timeseries: Timeseries[]; value: Maybe<number> };
}
export interface MobilePeriodStats {
@ -60,9 +62,10 @@ async function getMobileStats({
offset,
};
const [sessions, httpRequests] = await Promise.all([
const [sessions, httpRequests, crashes] = await Promise.all([
getMobileSessions({ ...commonProps }),
getMobileHttpRequests({ ...commonProps }),
getMobileCrashRate({ ...commonProps }),
]);
return {
@ -74,6 +77,18 @@ async function getMobileStats({
value: httpRequests.currentPeriod.value,
timeseries: httpRequests.currentPeriod.timeseries as Timeseries[],
},
crashRate: {
value: sessions.currentPeriod.value
? (crashes.currentPeriod.value ?? 0) / sessions.currentPeriod.value
: 0,
timeseries: crashes.currentPeriod.timeseries.map((bucket, i) => {
const sessionValue = sessions.currentPeriod.timeseries[i].y;
return {
x: bucket.x,
y: sessionValue ? (bucket.y ?? 0) / sessionValue : 0,
};
}) as Timeseries[],
},
};
}
@ -107,6 +122,7 @@ export async function getMobileStatsPeriods({
: {
sessions: { timeseries: [], value: null },
requests: { timeseries: [], value: null },
crashRate: { timeseries: [], value: null },
};
const [currentPeriod, previousPeriod] = await Promise.all([

View file

@ -8271,7 +8271,6 @@
"xpack.apm.mobile.location.metrics.http.requests.title": "Le plus utilisé dans",
"xpack.apm.mobile.location.metrics.launches": "La plupart des lancements",
"xpack.apm.mobile.location.metrics.sessions": "La plupart des sessions",
"xpack.apm.mobile.metrics.crash.rate": "Taux de panne (pannes par minute)",
"xpack.apm.mobile.metrics.http.requests": "Requêtes HTTP",
"xpack.apm.mobile.metrics.load.time": "Temps de chargement de l'application le plus lent",
"xpack.apm.mobile.metrics.sessions": "Sessions",

View file

@ -8287,7 +8287,6 @@
"xpack.apm.mobile.location.metrics.http.requests.title": "最も使用されている",
"xpack.apm.mobile.location.metrics.launches": "最も多い起動",
"xpack.apm.mobile.location.metrics.sessions": "最も多いセッション",
"xpack.apm.mobile.metrics.crash.rate": "クラッシュ率(毎分のクラッシュ数)",
"xpack.apm.mobile.metrics.http.requests": "HTTPリクエスト",
"xpack.apm.mobile.metrics.load.time": "最も遅いアプリ読み込み時間",
"xpack.apm.mobile.metrics.sessions": "セッション",

View file

@ -8286,7 +8286,6 @@
"xpack.apm.mobile.location.metrics.http.requests.title": "最常用于",
"xpack.apm.mobile.location.metrics.launches": "大多数启动",
"xpack.apm.mobile.location.metrics.sessions": "大多数会话",
"xpack.apm.mobile.metrics.crash.rate": "崩溃速率(每分钟崩溃数)",
"xpack.apm.mobile.metrics.http.requests": "HTTP 请求",
"xpack.apm.mobile.metrics.load.time": "最慢应用加载时间",
"xpack.apm.mobile.metrics.sessions": "会话",

View file

@ -219,6 +219,9 @@ export default function ApiTest({ getService }: FtrProviderContext) {
expect(response.currentPeriod.mostRequests.timeseries.every((item) => item.y === 0)).to.eql(
true
);
expect(response.currentPeriod.mostCrashes.timeseries.every((item) => item.y === 0)).to.eql(
true
);
});
});
});
@ -253,6 +256,11 @@ export default function ApiTest({ getService }: FtrProviderContext) {
const { location } = response.currentPeriod.mostRequests;
expect(location).to.be('China');
});
it('returns location for most crashes', () => {
const { location } = response.currentPeriod.mostCrashes;
expect(location).to.be('China');
});
});
describe('when filters are applied', () => {
@ -265,6 +273,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
expect(response.currentPeriod.mostSessions.value).to.eql(0);
expect(response.currentPeriod.mostRequests.value).to.eql(0);
expect(response.currentPeriod.mostCrashes.value).to.eql(0);
expect(response.currentPeriod.mostSessions.timeseries.every((item) => item.y === 0)).to.eql(
true
@ -272,6 +281,9 @@ export default function ApiTest({ getService }: FtrProviderContext) {
expect(response.currentPeriod.mostRequests.timeseries.every((item) => item.y === 0)).to.eql(
true
);
expect(response.currentPeriod.mostCrashes.timeseries.every((item) => item.y === 0)).to.eql(
true
);
});
it('returns the correct values when single filter is applied', async () => {
@ -283,6 +295,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
expect(response.currentPeriod.mostSessions.value).to.eql(3);
expect(response.currentPeriod.mostRequests.value).to.eql(3);
expect(response.currentPeriod.mostCrashes.value).to.eql(3);
});
it('returns the correct values when multiple filters are applied', async () => {
@ -293,6 +306,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
expect(response.currentPeriod.mostSessions.value).to.eql(3);
expect(response.currentPeriod.mostRequests.value).to.eql(3);
expect(response.currentPeriod.mostCrashes.value).to.eql(3);
});
});
});

View file

@ -10,7 +10,7 @@ import { apm, timerange } from '@kbn/apm-synthtrace-client';
import { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace';
import { APIReturnType } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
import { ENVIRONMENT_ALL } from '@kbn/apm-plugin/common/environment_filter_values';
import { sumBy } from 'lodash';
import { sumBy, meanBy } from 'lodash';
import { FtrProviderContext } from '../../common/ftr_provider_context';
type MobileStats = APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/stats'>;
@ -103,7 +103,7 @@ async function generateData({
return [
galaxy10
.transaction('Start View - View Appearing', 'Android Activity')
.errors(galaxy10.crash({ message: 'error' }).timestamp(timestamp))
.errors(galaxy10.crash({ message: 'error C' }).timestamp(timestamp))
.timestamp(timestamp)
.duration(500)
.success()
@ -120,7 +120,11 @@ async function generateData({
),
huaweiP2
.transaction('Start View - View Appearing', 'huaweiP2 Activity')
.errors(huaweiP2.crash({ message: 'error' }).timestamp(timestamp))
.errors(
huaweiP2.crash({ message: 'error A' }).timestamp(timestamp),
huaweiP2.crash({ message: 'error B' }).timestamp(timestamp),
huaweiP2.crash({ message: 'error D' }).timestamp(timestamp)
)
.timestamp(timestamp)
.duration(20)
.success(),
@ -211,6 +215,15 @@ export default function ApiTest({ getService }: FtrProviderContext) {
const timeseriesTotal = sumBy(timeseries, 'y');
expect(value).to.be(timeseriesTotal);
});
it('returns same crashes', () => {
const { value, timeseries } = response.currentPeriod.crashRate;
const timeseriesMean = meanBy(
timeseries.filter((bucket) => bucket.y !== 0),
'y'
);
expect(value).to.be(timeseriesMean);
});
});
describe('when filters are applied', () => {
@ -223,6 +236,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
expect(response.currentPeriod.sessions.value).to.eql(0);
expect(response.currentPeriod.requests.value).to.eql(0);
expect(response.currentPeriod.crashRate.value).to.eql(0);
expect(response.currentPeriod.sessions.timeseries.every((item) => item.y === 0)).to.eql(
true
@ -230,6 +244,9 @@ export default function ApiTest({ getService }: FtrProviderContext) {
expect(response.currentPeriod.requests.timeseries.every((item) => item.y === 0)).to.eql(
true
);
expect(response.currentPeriod.crashRate.timeseries.every((item) => item.y === 0)).to.eql(
true
);
});
it('returns the correct values when single filter is applied', async () => {
@ -241,6 +258,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
expect(response.currentPeriod.sessions.value).to.eql(3);
expect(response.currentPeriod.requests.value).to.eql(0);
expect(response.currentPeriod.crashRate.value).to.eql(3);
});
it('returns the correct values when multiple filters are applied', async () => {
@ -248,9 +266,9 @@ export default function ApiTest({ getService }: FtrProviderContext) {
serviceName: 'synth-android',
kuery: `service.version:"1.2" and service.environment: "production"`,
});
expect(response.currentPeriod.sessions.value).to.eql(3);
expect(response.currentPeriod.requests.value).to.eql(3);
expect(response.currentPeriod.crashRate.value).to.eql(1);
});
});
});