[APM] Mobile most launches (#168925)

## Summary

Enabling the "Most launches" Mobile dashboard panel which shows an
aggregation of log events that contain the attribute
`labels.lifecycle_state` set to either `created` (for Android) or
`active` (for iOS).

![Screenshot 2023-10-27 at 09 37 08
copy](911c769c-1456-4f38-bf07-5e71b6ce5ae5)


### Checklist

Delete any items that are not applicable to this PR.

- [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

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Katerina <kate@kpatticha.com>
This commit is contained in:
César 2023-11-06 14:14:10 +01:00 committed by GitHub
parent b56283932a
commit 820cfc02cf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 330 additions and 24 deletions

View file

@ -38,6 +38,7 @@ export type ApmUserAgentFields = Partial<{
export interface ApmException {
message: string;
}
export interface Observer {
type: string;
version: string;
@ -94,6 +95,7 @@ export type ApmFields = Fields<{
'error.type': string;
'event.ingested': number;
'event.name': string;
'event.action': string;
'event.outcome': string;
'event.outcome_numeric':
| number
@ -121,6 +123,7 @@ export type ApmFields = Fields<{
'kubernetes.pod.uid': string;
'labels.name': string;
'labels.telemetry_auto_version': string;
'labels.lifecycle_state': string;
'metricset.name': string;
'network.carrier.icc': string;
'network.carrier.mcc': string;

View file

@ -0,0 +1,30 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ApmFields } from './apm_fields';
import { Serializable } from '../serializable';
export class Event extends Serializable<ApmFields> {
constructor(fields: ApmFields) {
super({
...fields,
});
}
lifecycle(state: string): this {
this.fields['event.action'] = 'lifecycle';
this.fields['labels.lifecycle_state'] = state;
return this;
}
override timestamp(timestamp: number) {
const ret = super.timestamp(timestamp);
this.fields['timestamp.us'] = timestamp * 1000;
return ret;
}
}

View file

@ -9,7 +9,8 @@
import { Entity } from '../entity';
import { Span } from './span';
import { Transaction } from './transaction';
import { ApmFields, SpanParams, GeoLocation, ApmApplicationMetricFields } from './apm_fields';
import { Event } from './event';
import { ApmApplicationMetricFields, ApmFields, GeoLocation, SpanParams } from './apm_fields';
import { generateLongId } from '../utils/generate_id';
import { Metricset } from './metricset';
import { ApmError } from './apm_error';
@ -143,6 +144,10 @@ export class MobileDevice extends Entity<ApmFields> {
return this;
}
event(): Event {
return new Event({ ...this.fields });
}
transaction(
...options:
| [{ transactionName: string; frameworkName?: string; frameworkVersion?: string }]

View file

@ -7,6 +7,7 @@
*/
import { ApmError } from './apm_error';
import { Event } from './event';
import { BaseSpan } from './base_span';
import { generateShortId } from '../utils/generate_id';
import { ApmFields } from './apm_fields';
@ -15,6 +16,7 @@ import { getBreakdownMetrics } from './processors/get_breakdown_metrics';
export class Transaction extends BaseSpan {
private _sampled: boolean = true;
private readonly _errors: ApmError[] = [];
private readonly _events: Event[] = [];
constructor(fields: ApmFields) {
super({
@ -35,6 +37,27 @@ export class Transaction extends BaseSpan {
error.fields['transaction.sampled'] = this.fields['transaction.sampled'];
});
this._events.forEach((event) => {
event.fields['trace.id'] = this.fields['trace.id'];
event.fields['transaction.id'] = this.fields['transaction.id'];
event.fields['transaction.type'] = this.fields['transaction.type'];
event.fields['transaction.sampled'] = this.fields['transaction.sampled'];
});
return this;
}
events(...events: Event[]) {
events.forEach((event) => {
event.fields['trace.id'] = this.fields['trace.id'];
event.fields['transaction.id'] = this.fields['transaction.id'];
event.fields['transaction.name'] = this.fields['transaction.name'];
event.fields['transaction.type'] = this.fields['transaction.type'];
event.fields['transaction.sampled'] = this.fields['transaction.sampled'];
});
this._events.push(...events);
return this;
}
@ -62,6 +85,9 @@ export class Transaction extends BaseSpan {
this._errors.forEach((error) => {
error.fields['transaction.sampled'] = sampled;
});
this._events.forEach((event) => {
event.fields['transaction.sampled'] = sampled;
});
return this;
}
@ -69,6 +95,7 @@ export class Transaction extends BaseSpan {
const [transaction, ...spans] = super.serialize();
const errors = this._errors.flatMap((error) => error.serialize());
const logEvents = this._events.flatMap((event) => event.serialize());
const directChildren = this.getChildren().map((child) => child.fields);
@ -80,6 +107,6 @@ export class Transaction extends BaseSpan {
events.push(...spans);
}
return events.concat(errors).concat(breakdownMetrics);
return events.concat(errors).concat(breakdownMetrics).concat(logEvents);
}
}

View file

@ -46,6 +46,11 @@ export function getRoutingTransform() {
index = `metrics-apm.internal-${namespace}`;
}
break;
default:
if (document['event.action'] != null) {
index = `logs-apm.app-${namespace}`;
}
break;
}
if (!index) {

View file

@ -158,6 +158,8 @@ exports[`Error KUBERNETES_REPLICASET_NAME 1`] = `undefined`;
exports[`Error LABEL_GC 1`] = `undefined`;
exports[`Error LABEL_LIFECYCLE_STATE 1`] = `undefined`;
exports[`Error LABEL_NAME 1`] = `undefined`;
exports[`Error LABEL_TELEMETRY_AUTO_VERSION 1`] = `undefined`;
@ -485,6 +487,8 @@ exports[`Span KUBERNETES_REPLICASET_NAME 1`] = `undefined`;
exports[`Span LABEL_GC 1`] = `undefined`;
exports[`Span LABEL_LIFECYCLE_STATE 1`] = `undefined`;
exports[`Span LABEL_NAME 1`] = `undefined`;
exports[`Span LABEL_TELEMETRY_AUTO_VERSION 1`] = `undefined`;
@ -822,6 +826,8 @@ exports[`Transaction KUBERNETES_REPLICASET_NAME 1`] = `undefined`;
exports[`Transaction LABEL_GC 1`] = `undefined`;
exports[`Transaction LABEL_LIFECYCLE_STATE 1`] = `undefined`;
exports[`Transaction LABEL_NAME 1`] = `undefined`;
exports[`Transaction LABEL_TELEMETRY_AUTO_VERSION 1`] = `undefined`;

View file

@ -140,6 +140,7 @@ export const LABEL_NAME = 'labels.name';
export const LABEL_GC = 'labels.gc';
export const LABEL_TYPE = 'labels.type';
export const LABEL_TELEMETRY_AUTO_VERSION = 'labels.telemetry_auto_version';
export const LABEL_LIFECYCLE_STATE = 'labels.lifecycle_state';
export const HOST = 'host';
export const HOST_HOSTNAME = 'host.hostname'; // Do not use. Please use `HOST_NAME` instead.

View file

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

View file

@ -10,7 +10,6 @@ import type { APMIndices } from '@kbn/apm-data-access-plugin/server';
import { ProcessorEvent } from '@kbn/observability-plugin/common';
import { uniq } from 'lodash';
import { ApmDataSource } from '../../../../../common/data_source';
import {} from '../../../../../common/document_type';
import { PROCESSOR_EVENT } from '../../../../../common/es_fields/apm';
import {
getConfigForDocumentType,

View file

@ -11,8 +11,8 @@ import type {
FieldCapsResponse,
MsearchMultisearchBody,
MsearchMultisearchHeader,
TermsEnumResponse,
TermsEnumRequest,
TermsEnumResponse,
} from '@elastic/elasticsearch/lib/api/types';
import { ElasticsearchClient, KibanaRequest } from '@kbn/core/server';
import type { ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types';
@ -26,6 +26,7 @@ import { APMError } from '../../../../../typings/es_schemas/ui/apm_error';
import { Metric } from '../../../../../typings/es_schemas/ui/metric';
import { Span } from '../../../../../typings/es_schemas/ui/span';
import { Transaction } from '../../../../../typings/es_schemas/ui/transaction';
import { Event } from '../../../../../typings/es_schemas/ui/event';
import { withApmSpan } from '../../../../utils/with_apm_span';
import {
callAsyncWithDebug,
@ -46,6 +47,13 @@ export type APMEventESSearchRequest = Omit<ESSearchRequest, 'index'> & {
};
};
export type APMLogEventESSearchRequest = Omit<ESSearchRequest, 'index'> & {
body: {
size: number;
track_total_hits: boolean | number;
};
};
type APMEventWrapper<T> = Omit<T, 'index'> & {
apm: { events: ProcessorEvent[] };
};
@ -63,6 +71,9 @@ type TypeOfProcessorEvent<T extends ProcessorEvent> = {
metric: Metric;
}[T];
type TypedLogEventSearchResponse<TParams extends APMLogEventESSearchRequest> =
InferSearchResponseOf<Event, TParams>;
type TypedSearchResponse<TParams extends APMEventESSearchRequest> =
InferSearchResponseOf<
TypeOfProcessorEvent<
@ -196,6 +207,41 @@ export class APMEventClient {
});
}
async logEventSearch<TParams extends APMLogEventESSearchRequest>(
operationName: string,
params: TParams
): Promise<TypedLogEventSearchResponse<TParams>> {
// Reusing indices configured for errors since both events and errors are stored as logs.
const index = processorEventsToIndex([ProcessorEvent.error], this.indices);
const searchParams = {
...omit(params, 'body'),
index,
body: {
...params.body,
query: {
bool: {
must: compact([params.body.query]),
},
},
},
...(this.includeFrozen ? { ignore_throttled: false } : {}),
ignore_unavailable: true,
preference: 'any',
expand_wildcards: ['open' as const, 'hidden' as const],
};
return this.callAsyncWithDebug({
cb: (opts) =>
this.esClient.search(searchParams, opts) as unknown as Promise<{
body: TypedLogEventSearchResponse<TParams>;
}>,
operationName,
params: searchParams,
requestType: 'search',
});
}
async msearch<TParams extends APMEventESSearchRequest>(
operationName: string,
...allParams: TParams[]

View file

@ -11,7 +11,7 @@ import {
rangeQuery,
termQuery,
} from '@kbn/observability-plugin/server';
import { SERVICE_NAME, ERROR_TYPE } from '../../../common/es_fields/apm';
import { ERROR_TYPE, SERVICE_NAME } 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';
@ -101,9 +101,7 @@ export async function getCrashesByLocation({
timeseries:
response.aggregations?.timeseries?.buckets.map((bucket) => ({
x: bucket.key,
y:
response.aggregations?.crashes?.crashesByLocation?.buckets[0]
?.doc_count ?? 0,
y: bucket?.crashes?.crashesByLocation?.buckets[0]?.doc_count ?? 0,
})) ?? [],
};
}

View file

@ -6,15 +6,15 @@
*/
import {
termQuery,
kqlQuery,
rangeQuery,
termQuery,
} from '@kbn/observability-plugin/server';
import { ProcessorEvent } from '@kbn/observability-plugin/common';
import {
SERVICE_NAME,
SPAN_TYPE,
SPAN_SUBTYPE,
SPAN_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';
@ -111,9 +111,7 @@ export async function getHttpRequestsByLocation({
timeseries:
response.aggregations?.timeseries?.buckets.map((bucket) => ({
x: bucket.key,
y:
response.aggregations?.requests?.requestsByLocation?.buckets[0]
?.doc_count ?? 0,
y: bucket?.requests?.requestsByLocation?.buckets[0]?.doc_count ?? 0,
})) ?? [],
};
}

View file

@ -0,0 +1,112 @@
/*
* 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 {
LABEL_LIFECYCLE_STATE,
SERVICE_NAME,
} 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 getLaunchesByLocation({
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 = {
launches: {
filter: {
terms: { [LABEL_LIFECYCLE_STATE]: ['created', 'active'] },
},
aggs: {
byLocation: {
terms: {
field: locationField,
},
},
},
},
};
const response = await apmEventClient.logEventSearch(
'get_mobile_location_launches',
{
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?.launches?.byLocation?.buckets[0]
?.key as string,
value:
response.aggregations?.launches?.byLocation?.buckets[0]?.doc_count ?? 0,
timeseries:
response.aggregations?.timeseries?.buckets.map((bucket) => ({
x: bucket.key,
y: bucket.launches?.byLocation?.buckets[0]?.doc_count ?? 0,
})) ?? [],
};
}

View file

@ -10,6 +10,7 @@ import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_ev
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 { getLaunchesByLocation } from './get_mobile_launches_by_location';
import { Maybe } from '../../../typings/common';
export type Timeseries = Array<{ x: number; y: number }>;
@ -30,6 +31,11 @@ interface LocationStats {
value: Maybe<number>;
timeseries: Timeseries;
};
mostLaunches: {
location?: string;
value: Maybe<number>;
timeseries: Timeseries;
};
}
export interface MobileLocationStats {
@ -69,16 +75,19 @@ async function getMobileLocationStats({
offset,
};
const [mostSessions, mostRequests, mostCrashes] = await Promise.all([
getSessionsByLocation({ ...commonProps }),
getHttpRequestsByLocation({ ...commonProps }),
getCrashesByLocation({ ...commonProps }),
]);
const [mostSessions, mostRequests, mostCrashes, mostLaunches] =
await Promise.all([
getSessionsByLocation({ ...commonProps }),
getHttpRequestsByLocation({ ...commonProps }),
getCrashesByLocation({ ...commonProps }),
getLaunchesByLocation({ ...commonProps }),
]);
return {
mostSessions,
mostRequests,
mostCrashes,
mostLaunches,
};
}
@ -117,6 +126,7 @@ export async function getMobileLocationStatsPeriods({
mostSessions: { value: null, timeseries: [] },
mostRequests: { value: null, timeseries: [] },
mostCrashes: { value: null, timeseries: [] },
mostLaunches: { value: null, timeseries: [] },
};
const [currentPeriod, previousPeriod] = await Promise.all([

View file

@ -0,0 +1,25 @@
/*
* 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 { APMBaseDoc } from './apm_base_doc';
import { TimestampUs } from './fields/timestamp_us';
export interface EventRaw extends APMBaseDoc {
timestamp: TimestampUs;
transaction?: {
id: string;
sampled?: boolean;
type: string;
};
log: {
message?: string;
};
event: {
action: string;
category: string;
};
}

View file

@ -0,0 +1,13 @@
/*
* 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 { EventRaw } from '../raw/event_raw';
import { Agent } from './fields/agent';
export interface Event extends EventRaw {
agent: Agent;
}

View file

@ -130,7 +130,7 @@ async function generateData({
carrierMCC: '440',
});
return await synthtraceEsClient.index([
await synthtraceEsClient.index([
timerange(start, end)
.interval('5m')
.rate(1)
@ -142,6 +142,7 @@ async function generateData({
galaxy10
.transaction('Start View - View Appearing', 'Android Activity')
.errors(galaxy10.crash({ message: 'error' }).timestamp(timestamp))
.events(galaxy10.event().lifecycle('created').timestamp(timestamp))
.timestamp(timestamp)
.duration(500)
.success()
@ -159,12 +160,14 @@ async function generateData({
galaxy7
.transaction('Start View - View Appearing', 'Android Activity')
.errors(galaxy7.crash({ message: 'error' }).timestamp(timestamp))
.events(galaxy7.event().lifecycle('created').timestamp(timestamp))
.timestamp(timestamp)
.duration(20)
.success(),
huaweiP2
.transaction('Start View - View Appearing', 'huaweiP2 Activity')
.errors(huaweiP2.crash({ message: 'error' }).timestamp(timestamp))
.events(huaweiP2.event().lifecycle('created').timestamp(timestamp))
.timestamp(timestamp)
.duration(20)
.success(),
@ -222,6 +225,9 @@ export default function ApiTest({ getService }: FtrProviderContext) {
expect(response.currentPeriod.mostCrashes.timeseries.every((item) => item.y === 0)).to.eql(
true
);
expect(response.currentPeriod.mostLaunches.timeseries.every((item) => item.y === 0)).to.eql(
true
);
});
});
});
@ -261,6 +267,11 @@ export default function ApiTest({ getService }: FtrProviderContext) {
const { location } = response.currentPeriod.mostCrashes;
expect(location).to.be('China');
});
it('returns location for most launches', () => {
const { location } = response.currentPeriod.mostLaunches;
expect(location).to.be('China');
});
});
describe('when filters are applied', () => {
@ -274,6 +285,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.mostLaunches.value).to.eql(0);
expect(response.currentPeriod.mostSessions.timeseries.every((item) => item.y === 0)).to.eql(
true
@ -284,6 +296,9 @@ export default function ApiTest({ getService }: FtrProviderContext) {
expect(response.currentPeriod.mostCrashes.timeseries.every((item) => item.y === 0)).to.eql(
true
);
expect(response.currentPeriod.mostLaunches.timeseries.every((item) => item.y === 0)).to.eql(
true
);
});
it('returns the correct values when single filter is applied', async () => {
@ -293,9 +308,15 @@ export default function ApiTest({ getService }: FtrProviderContext) {
kuery: `service.version:"1.1"`,
});
expect(response.currentPeriod.mostSessions.timeseries[0].y).to.eql(1);
expect(response.currentPeriod.mostCrashes.timeseries[0].y).to.eql(1);
expect(response.currentPeriod.mostRequests.timeseries[0].y).to.eql(1);
expect(response.currentPeriod.mostLaunches.timeseries[0].y).to.eql(1);
expect(response.currentPeriod.mostSessions.value).to.eql(3);
expect(response.currentPeriod.mostRequests.value).to.eql(3);
expect(response.currentPeriod.mostCrashes.value).to.eql(3);
expect(response.currentPeriod.mostLaunches.value).to.eql(3);
});
it('returns the correct values when multiple filters are applied', async () => {
@ -304,9 +325,15 @@ export default function ApiTest({ getService }: FtrProviderContext) {
kuery: `service.version:"1.1" and service.environment: "production"`,
});
expect(response.currentPeriod.mostSessions.timeseries[0].y).to.eql(1);
expect(response.currentPeriod.mostCrashes.timeseries[0].y).to.eql(1);
expect(response.currentPeriod.mostRequests.timeseries[0].y).to.eql(1);
expect(response.currentPeriod.mostLaunches.timeseries[0].y).to.eql(1);
expect(response.currentPeriod.mostSessions.value).to.eql(3);
expect(response.currentPeriod.mostRequests.value).to.eql(3);
expect(response.currentPeriod.mostCrashes.value).to.eql(3);
expect(response.currentPeriod.mostLaunches.value).to.eql(3);
});
});
});