[RUM Dashboard] User experience metrics (#77384)
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
6c5258a8c3
commit
10b192b5b0
|
@ -5,11 +5,9 @@
|
|||
*/
|
||||
import * as React from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
|
||||
import { useFetcher } from '../../../../hooks/useFetcher';
|
||||
import { useUrlParams } from '../../../../hooks/useUrlParams';
|
||||
import { CLS_LABEL, FID_LABEL, LCP_LABEL } from './translations';
|
||||
import { CoreVitalItem } from './CoreVitalItem';
|
||||
import { UXMetrics } from '../UXMetrics';
|
||||
|
||||
const CoreVitalsThresholds = {
|
||||
LCP: { good: '2.5s', bad: '4.0s' },
|
||||
|
@ -17,27 +15,12 @@ const CoreVitalsThresholds = {
|
|||
CLS: { good: '0.1', bad: '0.25' },
|
||||
};
|
||||
|
||||
export function CoreVitals() {
|
||||
const { urlParams, uiFilters } = useUrlParams();
|
||||
|
||||
const { start, end } = urlParams;
|
||||
|
||||
const { data, status } = useFetcher(
|
||||
(callApmApi) => {
|
||||
const { serviceName } = uiFilters;
|
||||
if (start && end && serviceName) {
|
||||
return callApmApi({
|
||||
pathname: '/api/apm/rum-client/web-core-vitals',
|
||||
params: {
|
||||
query: { start, end, uiFilters: JSON.stringify(uiFilters) },
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
[start, end, uiFilters]
|
||||
);
|
||||
interface Props {
|
||||
data?: UXMetrics | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function CoreVitals({ data, loading }: Props) {
|
||||
const { lcp, lcpRanks, fid, fidRanks, cls, clsRanks } = data || {};
|
||||
|
||||
return (
|
||||
|
@ -47,7 +30,7 @@ export function CoreVitals() {
|
|||
title={LCP_LABEL}
|
||||
value={lcp ? lcp + 's' : '0'}
|
||||
ranks={lcpRanks}
|
||||
loading={status !== 'success'}
|
||||
loading={loading}
|
||||
thresholds={CoreVitalsThresholds.LCP}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
@ -56,7 +39,7 @@ export function CoreVitals() {
|
|||
title={FID_LABEL}
|
||||
value={fid ? fid + 's' : '0'}
|
||||
ranks={fidRanks}
|
||||
loading={status !== 'success'}
|
||||
loading={loading}
|
||||
thresholds={CoreVitalsThresholds.FID}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
@ -65,7 +48,7 @@ export function CoreVitals() {
|
|||
title={CLS_LABEL}
|
||||
value={cls ?? '0'}
|
||||
ranks={clsRanks}
|
||||
loading={status !== 'success'}
|
||||
loading={loading}
|
||||
thresholds={CoreVitalsThresholds.CLS}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -26,6 +26,27 @@ export const TBT_LABEL = i18n.translate('xpack.apm.rum.coreVitals.tbt', {
|
|||
defaultMessage: 'Total blocking time',
|
||||
});
|
||||
|
||||
export const NO_OF_LONG_TASK = i18n.translate(
|
||||
'xpack.apm.rum.uxMetrics.noOfLongTasks',
|
||||
{
|
||||
defaultMessage: 'No. of long tasks',
|
||||
}
|
||||
);
|
||||
|
||||
export const LONGEST_LONG_TASK = i18n.translate(
|
||||
'xpack.apm.rum.uxMetrics.longestLongTasks',
|
||||
{
|
||||
defaultMessage: 'Longest long task duration',
|
||||
}
|
||||
);
|
||||
|
||||
export const SUM_LONG_TASKS = i18n.translate(
|
||||
'xpack.apm.rum.uxMetrics.sumLongTasks',
|
||||
{
|
||||
defaultMessage: 'Total long tasks duration',
|
||||
}
|
||||
);
|
||||
|
||||
export const POOR_LABEL = i18n.translate('xpack.apm.rum.coreVitals.poor', {
|
||||
defaultMessage: 'a poor',
|
||||
});
|
||||
|
|
|
@ -17,7 +17,7 @@ import { PageViewsTrend } from './PageViewsTrend';
|
|||
import { PageLoadDistribution } from './PageLoadDistribution';
|
||||
import { I18LABELS } from './translations';
|
||||
import { VisitorBreakdown } from './VisitorBreakdown';
|
||||
import { CoreVitals } from './CoreVitals';
|
||||
import { UXMetrics } from './UXMetrics';
|
||||
import { VisitorBreakdownMap } from './VisitorBreakdownMap';
|
||||
|
||||
export function RumDashboard() {
|
||||
|
@ -37,17 +37,7 @@ export function RumDashboard() {
|
|||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiPanel>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={1} data-cy={`client-metrics`}>
|
||||
<EuiTitle size="xs">
|
||||
<h3>{I18LABELS.coreWebVitals}</h3>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<CoreVitals />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
<UXMetrics />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup gutterSize="s" wrap>
|
||||
|
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiFlexItem, EuiStat, EuiFlexGroup } from '@elastic/eui';
|
||||
import { UXMetrics } from './index';
|
||||
import {
|
||||
FCP_LABEL,
|
||||
LONGEST_LONG_TASK,
|
||||
NO_OF_LONG_TASK,
|
||||
SUM_LONG_TASKS,
|
||||
TBT_LABEL,
|
||||
} from '../CoreVitals/translations';
|
||||
import { useUrlParams } from '../../../../hooks/useUrlParams';
|
||||
import { useFetcher } from '../../../../hooks/useFetcher';
|
||||
|
||||
export function formatToSec(
|
||||
value?: number | string,
|
||||
fromUnit = 'MicroSec'
|
||||
): string {
|
||||
const valueInMs = Number(value ?? 0) / (fromUnit === 'MicroSec' ? 1000 : 1);
|
||||
|
||||
if (valueInMs < 1000) {
|
||||
return valueInMs + ' ms';
|
||||
}
|
||||
return (valueInMs / 1000).toFixed(2) + ' s';
|
||||
}
|
||||
const STAT_STYLE = { width: '240px' };
|
||||
|
||||
interface Props {
|
||||
data?: UXMetrics | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function KeyUXMetrics({ data, loading }: Props) {
|
||||
const { urlParams, uiFilters } = useUrlParams();
|
||||
|
||||
const { start, end, serviceName } = urlParams;
|
||||
|
||||
const { data: longTaskData, status } = useFetcher(
|
||||
(callApmApi) => {
|
||||
if (start && end && serviceName) {
|
||||
return callApmApi({
|
||||
pathname: '/api/apm/rum-client/long-task-metrics',
|
||||
params: {
|
||||
query: { start, end, uiFilters: JSON.stringify(uiFilters) },
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
[start, end, serviceName, uiFilters]
|
||||
);
|
||||
|
||||
// Note: FCP value is in ms unit
|
||||
return (
|
||||
<EuiFlexGroup responsive={false}>
|
||||
<EuiFlexItem grow={false} style={STAT_STYLE}>
|
||||
<EuiStat
|
||||
titleSize="s"
|
||||
title={formatToSec(data?.fcp, 'ms')}
|
||||
description={FCP_LABEL}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} style={STAT_STYLE}>
|
||||
<EuiStat
|
||||
titleSize="s"
|
||||
title={formatToSec(data?.tbt)}
|
||||
description={TBT_LABEL}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} style={STAT_STYLE}>
|
||||
<EuiStat
|
||||
titleSize="s"
|
||||
title={longTaskData?.noOfLongTasks ?? 0}
|
||||
description={NO_OF_LONG_TASK}
|
||||
isLoading={status !== 'success'}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} style={STAT_STYLE}>
|
||||
<EuiStat
|
||||
titleSize="s"
|
||||
title={formatToSec(longTaskData?.longestLongTask)}
|
||||
description={LONGEST_LONG_TASK}
|
||||
isLoading={status !== 'success'}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} style={STAT_STYLE}>
|
||||
<EuiStat
|
||||
titleSize="s"
|
||||
title={formatToSec(longTaskData?.sumOfLongTasks)}
|
||||
description={SUM_LONG_TASKS}
|
||||
isLoading={status !== 'success'}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { formatToSec } from '../KeyUXMetrics';
|
||||
|
||||
describe('FormatToSec', () => {
|
||||
test('it returns the expected value', () => {
|
||||
expect(formatToSec(3413000)).toStrictEqual('3.41 s');
|
||||
expect(formatToSec(15548000)).toStrictEqual('15.55 s');
|
||||
expect(formatToSec(1147.5, 'ms')).toStrictEqual('1.15 s');
|
||||
expect(formatToSec(114, 'ms')).toStrictEqual('114 ms');
|
||||
expect(formatToSec(undefined, 'ms')).toStrictEqual('0 ms');
|
||||
expect(formatToSec(undefined)).toStrictEqual('0 ms');
|
||||
expect(formatToSec('1123232')).toStrictEqual('1.12 s');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { I18LABELS } from '../translations';
|
||||
import { CoreVitals } from '../CoreVitals';
|
||||
import { KeyUXMetrics } from './KeyUXMetrics';
|
||||
import { useUrlParams } from '../../../../hooks/useUrlParams';
|
||||
import { useFetcher } from '../../../../hooks/useFetcher';
|
||||
|
||||
export interface UXMetrics {
|
||||
cls: string;
|
||||
fid: string;
|
||||
lcp: string;
|
||||
tbt: string;
|
||||
fcp: number;
|
||||
lcpRanks: number[];
|
||||
fidRanks: number[];
|
||||
clsRanks: number[];
|
||||
}
|
||||
|
||||
export function UXMetrics() {
|
||||
const { urlParams, uiFilters } = useUrlParams();
|
||||
|
||||
const { start, end } = urlParams;
|
||||
|
||||
const { data, status } = useFetcher(
|
||||
(callApmApi) => {
|
||||
const { serviceName } = uiFilters;
|
||||
if (start && end && serviceName) {
|
||||
return callApmApi({
|
||||
pathname: '/api/apm/rum-client/web-core-vitals',
|
||||
params: {
|
||||
query: { start, end, uiFilters: JSON.stringify(uiFilters) },
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
[start, end, uiFilters]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiPanel>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={1} data-cy={`client-metrics`}>
|
||||
<EuiTitle size="s">
|
||||
<h2>{I18LABELS.userExperienceMetrics}</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<KeyUXMetrics data={data} loading={status !== 'success'} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiHorizontalRule />
|
||||
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={1} data-cy={`client-metrics`}>
|
||||
<EuiTitle size="xs">
|
||||
<h3>{I18LABELS.coreWebVitals}</h3>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<CoreVitals data={data} loading={status !== 'success'} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
|
@ -64,6 +64,9 @@ export const I18LABELS = {
|
|||
defaultMessage: 'Operating system',
|
||||
}
|
||||
),
|
||||
userExperienceMetrics: i18n.translate('xpack.apm.rum.userExperienceMetrics', {
|
||||
defaultMessage: 'User experience metrics',
|
||||
}),
|
||||
avgPageLoadDuration: i18n.translate(
|
||||
'xpack.apm.rum.visitorBreakdownMap.avgPageLoadDuration',
|
||||
{
|
||||
|
|
|
@ -72,6 +72,64 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`rum client dashboard queries fetches long task metrics 1`] = `
|
||||
Object {
|
||||
"apm": Object {
|
||||
"events": Array [
|
||||
"span",
|
||||
],
|
||||
},
|
||||
"body": Object {
|
||||
"aggs": Object {
|
||||
"transIds": Object {
|
||||
"aggs": Object {
|
||||
"longestLongTask": Object {
|
||||
"max": Object {
|
||||
"field": "span.duration.us",
|
||||
},
|
||||
},
|
||||
"sumLongTask": Object {
|
||||
"sum": Object {
|
||||
"field": "span.duration.us",
|
||||
},
|
||||
},
|
||||
},
|
||||
"terms": Object {
|
||||
"field": "transaction.id",
|
||||
"size": 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
"query": Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"range": Object {
|
||||
"@timestamp": Object {
|
||||
"format": "epoch_millis",
|
||||
"gte": 1528113600000,
|
||||
"lte": 1528977600000,
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"term": Object {
|
||||
"span.type": "longtask",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"term": Object {
|
||||
"my.custom.ui.filter": "foo-bar",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"size": 0,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`rum client dashboard queries fetches page load distribution 1`] = `
|
||||
Object {
|
||||
"apm": Object {
|
||||
|
@ -190,6 +248,126 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`rum client dashboard queries fetches rum core vitals 1`] = `
|
||||
Object {
|
||||
"apm": Object {
|
||||
"events": Array [
|
||||
"transaction",
|
||||
],
|
||||
},
|
||||
"body": Object {
|
||||
"aggs": Object {
|
||||
"cls": Object {
|
||||
"percentiles": Object {
|
||||
"field": "transaction.experience.cls",
|
||||
"percents": Array [
|
||||
50,
|
||||
],
|
||||
},
|
||||
},
|
||||
"clsRanks": Object {
|
||||
"percentile_ranks": Object {
|
||||
"field": "transaction.experience.cls",
|
||||
"keyed": false,
|
||||
"values": Array [
|
||||
0.1,
|
||||
0.25,
|
||||
],
|
||||
},
|
||||
},
|
||||
"fcp": Object {
|
||||
"percentiles": Object {
|
||||
"field": "transaction.marks.agent.firstContentfulPaint",
|
||||
"percents": Array [
|
||||
50,
|
||||
],
|
||||
},
|
||||
},
|
||||
"fid": Object {
|
||||
"percentiles": Object {
|
||||
"field": "transaction.experience.fid",
|
||||
"percents": Array [
|
||||
50,
|
||||
],
|
||||
},
|
||||
},
|
||||
"fidRanks": Object {
|
||||
"percentile_ranks": Object {
|
||||
"field": "transaction.experience.fid",
|
||||
"keyed": false,
|
||||
"values": Array [
|
||||
100,
|
||||
300,
|
||||
],
|
||||
},
|
||||
},
|
||||
"lcp": Object {
|
||||
"percentiles": Object {
|
||||
"field": "transaction.marks.agent.largestContentfulPaint",
|
||||
"percents": Array [
|
||||
50,
|
||||
],
|
||||
},
|
||||
},
|
||||
"lcpRanks": Object {
|
||||
"percentile_ranks": Object {
|
||||
"field": "transaction.marks.agent.largestContentfulPaint",
|
||||
"keyed": false,
|
||||
"values": Array [
|
||||
2500,
|
||||
4000,
|
||||
],
|
||||
},
|
||||
},
|
||||
"tbt": Object {
|
||||
"percentiles": Object {
|
||||
"field": "transaction.experience.tbt",
|
||||
"percents": Array [
|
||||
50,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"query": Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"range": Object {
|
||||
"@timestamp": Object {
|
||||
"format": "epoch_millis",
|
||||
"gte": 1528113600000,
|
||||
"lte": 1528977600000,
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"term": Object {
|
||||
"transaction.type": "page-load",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"exists": Object {
|
||||
"field": "transaction.marks.navigationTiming.fetchStart",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"term": Object {
|
||||
"my.custom.ui.filter": "foo-bar",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"term": Object {
|
||||
"user_agent.name": "Chrome",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"size": 0,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`rum client dashboard queries fetches rum services 1`] = `
|
||||
Object {
|
||||
"apm": Object {
|
||||
|
|
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import {
|
||||
getRumLongTasksProjection,
|
||||
getRumOverviewProjection,
|
||||
} from '../../projections/rum_overview';
|
||||
import { mergeProjection } from '../../projections/util/merge_projection';
|
||||
import {
|
||||
Setup,
|
||||
SetupTimeRange,
|
||||
SetupUIFilters,
|
||||
} from '../helpers/setup_request';
|
||||
import { SPAN_DURATION } from '../../../common/elasticsearch_fieldnames';
|
||||
|
||||
export async function getLongTaskMetrics({
|
||||
setup,
|
||||
}: {
|
||||
setup: Setup & SetupTimeRange & SetupUIFilters;
|
||||
}) {
|
||||
const projection = getRumLongTasksProjection({
|
||||
setup,
|
||||
});
|
||||
|
||||
const params = mergeProjection(projection, {
|
||||
body: {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: projection.body.query.bool,
|
||||
},
|
||||
aggs: {
|
||||
transIds: {
|
||||
terms: {
|
||||
field: 'transaction.id',
|
||||
size: 1000,
|
||||
},
|
||||
aggs: {
|
||||
sumLongTask: {
|
||||
sum: {
|
||||
field: SPAN_DURATION,
|
||||
},
|
||||
},
|
||||
longestLongTask: {
|
||||
max: {
|
||||
field: SPAN_DURATION,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { apmEventClient } = setup;
|
||||
|
||||
const response = await apmEventClient.search(params);
|
||||
const { transIds } = response.aggregations ?? {};
|
||||
|
||||
const validTransactions: string[] = await filterPageLoadTransactions(
|
||||
setup,
|
||||
(transIds?.buckets ?? []).map((bucket) => bucket.key as string)
|
||||
);
|
||||
let noOfLongTasks = 0;
|
||||
let sumOfLongTasks = 0;
|
||||
let longestLongTask = 0;
|
||||
|
||||
(transIds?.buckets ?? []).forEach((bucket) => {
|
||||
if (validTransactions.includes(bucket.key as string)) {
|
||||
noOfLongTasks += bucket.doc_count;
|
||||
sumOfLongTasks += bucket.sumLongTask.value ?? 0;
|
||||
if ((bucket.longestLongTask.value ?? 0) > longestLongTask) {
|
||||
longestLongTask = bucket.longestLongTask.value!;
|
||||
}
|
||||
}
|
||||
});
|
||||
return {
|
||||
noOfLongTasks,
|
||||
sumOfLongTasks,
|
||||
longestLongTask,
|
||||
};
|
||||
}
|
||||
|
||||
async function filterPageLoadTransactions(
|
||||
setup: Setup & SetupTimeRange & SetupUIFilters,
|
||||
transactionIds: string[]
|
||||
) {
|
||||
const projection = getRumOverviewProjection({
|
||||
setup,
|
||||
});
|
||||
|
||||
const params = mergeProjection(projection, {
|
||||
body: {
|
||||
size: transactionIds.length,
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
terms: {
|
||||
'transaction.id': transactionIds,
|
||||
},
|
||||
},
|
||||
],
|
||||
filter: [...projection.body.query.bool.filter],
|
||||
},
|
||||
},
|
||||
_source: ['transaction.id'],
|
||||
},
|
||||
});
|
||||
|
||||
const { apmEventClient } = setup;
|
||||
|
||||
const response = await apmEventClient.search(params);
|
||||
return response.hits.hits.map((hit) => (hit._source as any).transaction.id)!;
|
||||
}
|
|
@ -13,8 +13,10 @@ import {
|
|||
} from '../helpers/setup_request';
|
||||
import {
|
||||
CLS_FIELD,
|
||||
FCP_FIELD,
|
||||
FID_FIELD,
|
||||
LCP_FIELD,
|
||||
TBT_FIELD,
|
||||
} from '../../../common/elasticsearch_fieldnames';
|
||||
|
||||
export async function getWebCoreVitals({
|
||||
|
@ -60,6 +62,18 @@ export async function getWebCoreVitals({
|
|||
percents: [50],
|
||||
},
|
||||
},
|
||||
tbt: {
|
||||
percentiles: {
|
||||
field: TBT_FIELD,
|
||||
percents: [50],
|
||||
},
|
||||
},
|
||||
fcp: {
|
||||
percentiles: {
|
||||
field: FCP_FIELD,
|
||||
percents: [50],
|
||||
},
|
||||
},
|
||||
lcpRanks: {
|
||||
percentile_ranks: {
|
||||
field: LCP_FIELD,
|
||||
|
@ -88,21 +102,13 @@ export async function getWebCoreVitals({
|
|||
const { apmEventClient } = setup;
|
||||
|
||||
const response = await apmEventClient.search(params);
|
||||
const {
|
||||
lcp,
|
||||
cls,
|
||||
fid,
|
||||
lcpRanks,
|
||||
fidRanks,
|
||||
clsRanks,
|
||||
} = response.aggregations!;
|
||||
const { lcp, cls, fid, tbt, fcp, lcpRanks, fidRanks, clsRanks } =
|
||||
response.aggregations ?? {};
|
||||
|
||||
const getRanksPercentages = (
|
||||
ranks: Array<{ key: number; value: number }>
|
||||
) => {
|
||||
const ranksVal = (ranks ?? [0, 0]).map(
|
||||
({ value }) => value?.toFixed(0) ?? 0
|
||||
);
|
||||
const ranksVal = ranks.map(({ value }) => value?.toFixed(0) ?? 0);
|
||||
return [
|
||||
Number(ranksVal?.[0]),
|
||||
Number(ranksVal?.[1]) - Number(ranksVal?.[0]),
|
||||
|
@ -110,14 +116,21 @@ export async function getWebCoreVitals({
|
|||
];
|
||||
};
|
||||
|
||||
const defaultRanks = [
|
||||
{ value: 0, key: 0 },
|
||||
{ value: 0, key: 0 },
|
||||
];
|
||||
|
||||
// Divide by 1000 to convert ms into seconds
|
||||
return {
|
||||
cls: String(cls.values['50.0'] || 0),
|
||||
fid: ((fid.values['50.0'] || 0) / 1000).toFixed(2),
|
||||
lcp: ((lcp.values['50.0'] || 0) / 1000).toFixed(2),
|
||||
cls: String(cls?.values['50.0'] || 0),
|
||||
fid: ((fid?.values['50.0'] || 0) / 1000).toFixed(2),
|
||||
lcp: ((lcp?.values['50.0'] || 0) / 1000).toFixed(2),
|
||||
tbt: ((tbt?.values['50.0'] || 0) / 1000).toFixed(2),
|
||||
fcp: fcp?.values['50.0'] || 0,
|
||||
|
||||
lcpRanks: getRanksPercentages(lcpRanks.values),
|
||||
fidRanks: getRanksPercentages(fidRanks.values),
|
||||
clsRanks: getRanksPercentages(clsRanks.values),
|
||||
lcpRanks: getRanksPercentages(lcpRanks?.values ?? defaultRanks),
|
||||
fidRanks: getRanksPercentages(fidRanks?.values ?? defaultRanks),
|
||||
clsRanks: getRanksPercentages(clsRanks?.values ?? defaultRanks),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -12,6 +12,8 @@ import { getClientMetrics } from './get_client_metrics';
|
|||
import { getPageViewTrends } from './get_page_view_trends';
|
||||
import { getPageLoadDistribution } from './get_page_load_distribution';
|
||||
import { getRumServices } from './get_rum_services';
|
||||
import { getLongTaskMetrics } from './get_long_task_metrics';
|
||||
import { getWebCoreVitals } from './get_web_core_vitals';
|
||||
|
||||
describe('rum client dashboard queries', () => {
|
||||
let mock: SearchParamsMock;
|
||||
|
@ -59,4 +61,22 @@ describe('rum client dashboard queries', () => {
|
|||
);
|
||||
expect(mock.params).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('fetches rum core vitals', async () => {
|
||||
mock = await inspectSearchParams((setup) =>
|
||||
getWebCoreVitals({
|
||||
setup,
|
||||
})
|
||||
);
|
||||
expect(mock.params).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('fetches long task metrics', async () => {
|
||||
mock = await inspectSearchParams((setup) =>
|
||||
getLongTaskMetrics({
|
||||
setup,
|
||||
})
|
||||
);
|
||||
expect(mock.params).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,7 +9,10 @@ import {
|
|||
SetupTimeRange,
|
||||
SetupUIFilters,
|
||||
} from '../../server/lib/helpers/setup_request';
|
||||
import { TRANSACTION_TYPE } from '../../common/elasticsearch_fieldnames';
|
||||
import {
|
||||
SPAN_TYPE,
|
||||
TRANSACTION_TYPE,
|
||||
} from '../../common/elasticsearch_fieldnames';
|
||||
import { rangeFilter } from '../../common/utils/range_filter';
|
||||
import { ProcessorEvent } from '../../common/processor_event';
|
||||
|
||||
|
@ -45,3 +48,30 @@ export function getRumOverviewProjection({
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getRumLongTasksProjection({
|
||||
setup,
|
||||
}: {
|
||||
setup: Setup & SetupTimeRange & SetupUIFilters;
|
||||
}) {
|
||||
const { start, end, uiFiltersES } = setup;
|
||||
|
||||
const bool = {
|
||||
filter: [
|
||||
{ range: rangeFilter(start, end) },
|
||||
{ term: { [SPAN_TYPE]: 'longtask' } },
|
||||
...uiFiltersES,
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
apm: {
|
||||
events: [ProcessorEvent.span],
|
||||
},
|
||||
body: {
|
||||
query: {
|
||||
bool,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -78,6 +78,7 @@ import {
|
|||
rumServicesRoute,
|
||||
rumVisitorsBreakdownRoute,
|
||||
rumWebCoreVitals,
|
||||
rumLongTaskMetrics,
|
||||
} from './rum_client';
|
||||
import {
|
||||
observabilityOverviewHasDataRoute,
|
||||
|
@ -174,6 +175,7 @@ const createApmApi = () => {
|
|||
.add(rumServicesRoute)
|
||||
.add(rumVisitorsBreakdownRoute)
|
||||
.add(rumWebCoreVitals)
|
||||
.add(rumLongTaskMetrics)
|
||||
|
||||
// Observability dashboard
|
||||
.add(observabilityOverviewHasDataRoute)
|
||||
|
|
|
@ -15,6 +15,7 @@ import { getPageLoadDistBreakdown } from '../lib/rum_client/get_pl_dist_breakdow
|
|||
import { getRumServices } from '../lib/rum_client/get_rum_services';
|
||||
import { getVisitorBreakdown } from '../lib/rum_client/get_visitor_breakdown';
|
||||
import { getWebCoreVitals } from '../lib/rum_client/get_web_core_vitals';
|
||||
import { getLongTaskMetrics } from '../lib/rum_client/get_long_task_metrics';
|
||||
|
||||
export const percentileRangeRt = t.partial({
|
||||
minPercentile: t.string,
|
||||
|
@ -130,3 +131,15 @@ export const rumWebCoreVitals = createRoute(() => ({
|
|||
return getWebCoreVitals({ setup });
|
||||
},
|
||||
}));
|
||||
|
||||
export const rumLongTaskMetrics = createRoute(() => ({
|
||||
path: '/api/apm/rum-client/long-task-metrics',
|
||||
params: {
|
||||
query: t.intersection([uiFiltersRt, rangeRt]),
|
||||
},
|
||||
handler: async ({ context, request }) => {
|
||||
const setup = await setupRequest(context, request);
|
||||
|
||||
return getLongTaskMetrics({ setup });
|
||||
},
|
||||
}));
|
||||
|
|
|
@ -94,6 +94,9 @@ export interface AggregationOptionsByType {
|
|||
percents?: number[];
|
||||
hdr?: { number_of_significant_value_digits: number };
|
||||
} & AggregationSourceOptions;
|
||||
stats: {
|
||||
field: string;
|
||||
};
|
||||
extended_stats: {
|
||||
field: string;
|
||||
};
|
||||
|
@ -223,6 +226,13 @@ interface AggregationResponsePart<
|
|||
percentiles: {
|
||||
values: Record<string, number | null>;
|
||||
};
|
||||
stats: {
|
||||
count: number;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
avg: number | null;
|
||||
sum: number | null;
|
||||
};
|
||||
extended_stats: {
|
||||
count: number;
|
||||
min: number | null;
|
||||
|
|
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
@ -12,7 +12,7 @@ export default function rumServicesApiTests({ getService }: FtrProviderContext)
|
|||
const supertest = getService('supertest');
|
||||
const esArchiver = getService('esArchiver');
|
||||
|
||||
describe('RUM Services', () => {
|
||||
describe('CSM Services', () => {
|
||||
describe('when there is no data', () => {
|
||||
it('returns empty list', async () => {
|
||||
const response = await supertest.get(
|
||||
|
@ -41,12 +41,7 @@ export default function rumServicesApiTests({ getService }: FtrProviderContext)
|
|||
|
||||
expect(response.status).to.be(200);
|
||||
|
||||
expectSnapshot(response.body).toMatchInline(`
|
||||
Array [
|
||||
"client",
|
||||
"opbean-client-rum",
|
||||
]
|
||||
`);
|
||||
expectSnapshot(response.body).toMatchInline(`Array []`);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { expectSnapshot } from '../../../common/match_snapshot';
|
||||
import { FtrProviderContext } from '../../../common/ftr_provider_context';
|
||||
|
||||
export default function rumServicesApiTests({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const esArchiver = getService('esArchiver');
|
||||
|
||||
describe('CSM long task metrics', () => {
|
||||
describe('when there is no data', () => {
|
||||
it('returns empty list', async () => {
|
||||
const response = await supertest.get(
|
||||
'/api/apm/rum-client/long-task-metrics?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-14T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22elastic-co-rum-test%22%5D%7D'
|
||||
);
|
||||
|
||||
expect(response.status).to.be(200);
|
||||
expect(response.body).to.eql({
|
||||
longestLongTask: 0,
|
||||
noOfLongTasks: 0,
|
||||
sumOfLongTasks: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there is data', () => {
|
||||
before(async () => {
|
||||
await esArchiver.load('8.0.0');
|
||||
await esArchiver.load('rum_8.0.0');
|
||||
});
|
||||
after(async () => {
|
||||
await esArchiver.unload('8.0.0');
|
||||
await esArchiver.unload('rum_8.0.0');
|
||||
});
|
||||
|
||||
it('returns web core vitals values', async () => {
|
||||
const response = await supertest.get(
|
||||
'/api/apm/rum-client/long-task-metrics?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-16T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22kibana-frontend-8_0_0%22%5D%7D'
|
||||
);
|
||||
|
||||
expect(response.status).to.be(200);
|
||||
|
||||
expectSnapshot(response.body).toMatchInline(`
|
||||
Object {
|
||||
"longestLongTask": 109000,
|
||||
"noOfLongTasks": 2,
|
||||
"sumOfLongTasks": 168000,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { expectSnapshot } from '../../../common/match_snapshot';
|
||||
import { FtrProviderContext } from '../../../common/ftr_provider_context';
|
||||
|
||||
export default function rumServicesApiTests({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const esArchiver = getService('esArchiver');
|
||||
|
||||
describe('CSM web core vitals', () => {
|
||||
describe('when there is no data', () => {
|
||||
it('returns empty list', async () => {
|
||||
const response = await supertest.get(
|
||||
'/api/apm/rum-client/web-core-vitals?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-14T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22elastic-co-rum-test%22%5D%7D'
|
||||
);
|
||||
|
||||
expect(response.status).to.be(200);
|
||||
expect(response.body).to.eql({
|
||||
cls: '0',
|
||||
fid: '0.00',
|
||||
lcp: '0.00',
|
||||
tbt: '0.00',
|
||||
fcp: 0,
|
||||
lcpRanks: [0, 0, 100],
|
||||
fidRanks: [0, 0, 100],
|
||||
clsRanks: [0, 0, 100],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there is data', () => {
|
||||
before(async () => {
|
||||
await esArchiver.load('8.0.0');
|
||||
await esArchiver.load('rum_8.0.0');
|
||||
});
|
||||
after(async () => {
|
||||
await esArchiver.unload('8.0.0');
|
||||
await esArchiver.unload('rum_8.0.0');
|
||||
});
|
||||
|
||||
it('returns web core vitals values', async () => {
|
||||
const response = await supertest.get(
|
||||
'/api/apm/rum-client/web-core-vitals?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-16T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22kibana-frontend-8_0_0%22%5D%7D'
|
||||
);
|
||||
|
||||
expect(response.status).to.be(200);
|
||||
|
||||
expectSnapshot(response.body).toMatchInline(`
|
||||
Object {
|
||||
"cls": "0",
|
||||
"clsRanks": Array [
|
||||
100,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"fcp": 1072,
|
||||
"fid": "1.35",
|
||||
"fidRanks": Array [
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
],
|
||||
"lcp": "1.27",
|
||||
"lcpRanks": Array [
|
||||
100,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"tbt": "0.00",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -15,7 +15,6 @@ export default function observabilityApiIntegrationTests({ loadTestFile }: FtrPr
|
|||
|
||||
describe('Services', function () {
|
||||
loadTestFile(require.resolve('./services/annotations'));
|
||||
loadTestFile(require.resolve('./services/rum_services.ts'));
|
||||
loadTestFile(require.resolve('./services/top_services.ts'));
|
||||
});
|
||||
|
||||
|
@ -30,5 +29,11 @@ export default function observabilityApiIntegrationTests({ loadTestFile }: FtrPr
|
|||
describe('Service Maps', function () {
|
||||
loadTestFile(require.resolve('./service_maps/service_maps'));
|
||||
});
|
||||
|
||||
describe('CSM', function () {
|
||||
loadTestFile(require.resolve('./csm/csm_services.ts'));
|
||||
loadTestFile(require.resolve('./csm/web_core_vitals.ts'));
|
||||
loadTestFile(require.resolve('./csm/long_task_metrics.ts'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue