[RUM Dashboard] Added rum core web vitals (#75685)

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Shahzad 2020-09-08 17:19:48 +02:00 committed by GitHub
parent caa4e0c11b
commit e827a6761e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 740 additions and 22 deletions

View file

@ -14,6 +14,8 @@ exports[`Error CLOUD_PROVIDER 1`] = `"gcp"`;
exports[`Error CLOUD_REGION 1`] = `"europe-west1"`;
exports[`Error CLS_FIELD 1`] = `undefined`;
exports[`Error CONTAINER_ID 1`] = `undefined`;
exports[`Error DESTINATION_ADDRESS 1`] = `undefined`;
@ -34,6 +36,10 @@ exports[`Error ERROR_LOG_MESSAGE 1`] = `undefined`;
exports[`Error ERROR_PAGE_URL 1`] = `undefined`;
exports[`Error FCP_FIELD 1`] = `undefined`;
exports[`Error FID_FIELD 1`] = `undefined`;
exports[`Error EVENT_OUTCOME 1`] = `undefined`;
exports[`Error HOST_NAME 1`] = `"my hostname"`;
@ -44,6 +50,8 @@ exports[`Error HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`;
exports[`Error LABEL_NAME 1`] = `undefined`;
exports[`Error LCP_FIELD 1`] = `undefined`;
exports[`Error METRIC_JAVA_GC_COUNT 1`] = `undefined`;
exports[`Error METRIC_JAVA_GC_TIME 1`] = `undefined`;
@ -118,6 +126,8 @@ exports[`Error SPAN_SUBTYPE 1`] = `undefined`;
exports[`Error SPAN_TYPE 1`] = `undefined`;
exports[`Error TBT_FIELD 1`] = `undefined`;
exports[`Error TRACE_ID 1`] = `"trace id"`;
exports[`Error TRANSACTION_BREAKDOWN_COUNT 1`] = `undefined`;
@ -168,6 +178,8 @@ exports[`Span CLOUD_PROVIDER 1`] = `"gcp"`;
exports[`Span CLOUD_REGION 1`] = `"europe-west1"`;
exports[`Span CLS_FIELD 1`] = `undefined`;
exports[`Span CONTAINER_ID 1`] = `undefined`;
exports[`Span DESTINATION_ADDRESS 1`] = `undefined`;
@ -188,6 +200,10 @@ exports[`Span ERROR_LOG_MESSAGE 1`] = `undefined`;
exports[`Span ERROR_PAGE_URL 1`] = `undefined`;
exports[`Span FCP_FIELD 1`] = `undefined`;
exports[`Span FID_FIELD 1`] = `undefined`;
exports[`Span EVENT_OUTCOME 1`] = `undefined`;
exports[`Span HOST_NAME 1`] = `undefined`;
@ -198,6 +214,8 @@ exports[`Span HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`;
exports[`Span LABEL_NAME 1`] = `undefined`;
exports[`Span LCP_FIELD 1`] = `undefined`;
exports[`Span METRIC_JAVA_GC_COUNT 1`] = `undefined`;
exports[`Span METRIC_JAVA_GC_TIME 1`] = `undefined`;
@ -272,6 +290,8 @@ exports[`Span SPAN_SUBTYPE 1`] = `"my subtype"`;
exports[`Span SPAN_TYPE 1`] = `"span type"`;
exports[`Span TBT_FIELD 1`] = `undefined`;
exports[`Span TRACE_ID 1`] = `"trace id"`;
exports[`Span TRANSACTION_BREAKDOWN_COUNT 1`] = `undefined`;
@ -322,6 +342,8 @@ exports[`Transaction CLOUD_PROVIDER 1`] = `"gcp"`;
exports[`Transaction CLOUD_REGION 1`] = `"europe-west1"`;
exports[`Transaction CLS_FIELD 1`] = `undefined`;
exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`;
exports[`Transaction DESTINATION_ADDRESS 1`] = `undefined`;
@ -342,6 +364,10 @@ exports[`Transaction ERROR_LOG_MESSAGE 1`] = `undefined`;
exports[`Transaction ERROR_PAGE_URL 1`] = `undefined`;
exports[`Transaction FCP_FIELD 1`] = `undefined`;
exports[`Transaction FID_FIELD 1`] = `undefined`;
exports[`Transaction EVENT_OUTCOME 1`] = `undefined`;
exports[`Transaction HOST_NAME 1`] = `"my hostname"`;
@ -352,6 +378,8 @@ exports[`Transaction HTTP_RESPONSE_STATUS_CODE 1`] = `200`;
exports[`Transaction LABEL_NAME 1`] = `undefined`;
exports[`Transaction LCP_FIELD 1`] = `undefined`;
exports[`Transaction METRIC_JAVA_GC_COUNT 1`] = `undefined`;
exports[`Transaction METRIC_JAVA_GC_TIME 1`] = `undefined`;
@ -426,6 +454,8 @@ exports[`Transaction SPAN_SUBTYPE 1`] = `undefined`;
exports[`Transaction SPAN_TYPE 1`] = `undefined`;
exports[`Transaction TBT_FIELD 1`] = `undefined`;
exports[`Transaction TRACE_ID 1`] = `"trace id"`;
exports[`Transaction TRANSACTION_BREAKDOWN_COUNT 1`] = `undefined`;

View file

@ -106,3 +106,9 @@ export const TRANSACTION_TIME_TO_FIRST_BYTE =
'transaction.marks.agent.timeToFirstByte';
export const TRANSACTION_DOM_INTERACTIVE =
'transaction.marks.agent.domInteractive';
export const FCP_FIELD = 'transaction.marks.agent.firstContentfulPaint';
export const LCP_FIELD = 'transaction.marks.agent.largestContentfulPaint';
export const TBT_FIELD = 'transaction.experience.tbt';
export const FID_FIELD = 'transaction.experience.fid';
export const CLS_FIELD = 'transaction.experience.cls';

View file

@ -56,7 +56,7 @@ export function ClientMetrics() {
<EuiFlexItem grow={false} style={STAT_STYLE}>
<EuiStat
titleSize="s"
title={(data?.frontEnd?.value?.toFixed(2) ?? '-') + ' s'}
title={((data?.frontEnd?.value ?? 0)?.toFixed(2) ?? '-') + ' s'}
description={I18LABELS.frontEnd}
isLoading={status !== 'success'}
/>

View file

@ -0,0 +1,72 @@
/*
* 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, EuiToolTip } from '@elastic/eui';
import styled from 'styled-components';
const ColoredSpan = styled.div`
height: 16px;
width: 100%;
cursor: pointer;
`;
const getSpanStyle = (
position: number,
inFocus: boolean,
hexCode: string,
percentage: number
) => {
let first = position === 0 || percentage === 100;
let last = position === 2 || percentage === 100;
if (percentage === 100) {
first = true;
last = true;
}
const spanStyle: any = {
backgroundColor: hexCode,
opacity: !inFocus ? 1 : 0.3,
};
let borderRadius = '';
if (first) {
borderRadius = '4px 0 0 4px';
}
if (last) {
borderRadius = '0 4px 4px 0';
}
if (first && last) {
borderRadius = '4px';
}
spanStyle.borderRadius = borderRadius;
return spanStyle;
};
export function ColorPaletteFlexItem({
hexCode,
inFocus,
percentage,
tooltip,
position,
}: {
hexCode: string;
position: number;
inFocus: boolean;
percentage: number;
tooltip: string;
}) {
const spanStyle = getSpanStyle(position, inFocus, hexCode, percentage);
return (
<EuiFlexItem key={hexCode} grow={false} style={{ width: percentage + '%' }}>
<EuiToolTip content={tooltip}>
<ColoredSpan style={spanStyle} />
</EuiToolTip>
</EuiFlexItem>
);
}

View file

@ -0,0 +1,124 @@
/*
* 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 {
EuiFlexGroup,
euiPaletteForStatus,
EuiSpacer,
EuiStat,
} from '@elastic/eui';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { PaletteLegends } from './PaletteLegends';
import { ColorPaletteFlexItem } from './ColorPaletteFlexItem';
import {
AVERAGE_LABEL,
GOOD_LABEL,
LESS_LABEL,
MORE_LABEL,
POOR_LABEL,
} from './translations';
export interface Thresholds {
good: string;
bad: string;
}
interface Props {
title: string;
value: string;
ranks?: number[];
loading: boolean;
thresholds: Thresholds;
}
export function getCoreVitalTooltipMessage(
thresholds: Thresholds,
position: number,
title: string,
percentage: number
) {
const good = position === 0;
const bad = position === 2;
const average = !good && !bad;
return i18n.translate('xpack.apm.csm.dashboard.webVitals.palette.tooltip', {
defaultMessage:
'{percentage} % of users have {exp} experience because the {title} takes {moreOrLess} than {value}{averageMessage}.',
values: {
percentage,
title: title?.toLowerCase(),
exp: good ? GOOD_LABEL : bad ? POOR_LABEL : AVERAGE_LABEL,
moreOrLess: bad || average ? MORE_LABEL : LESS_LABEL,
value: good || average ? thresholds.good : thresholds.bad,
averageMessage: average
? i18n.translate('xpack.apm.rum.coreVitals.averageMessage', {
defaultMessage: ' and less than {bad}',
values: { bad: thresholds.bad },
})
: '',
},
});
}
export function CoreVitalItem({
loading,
title,
value,
thresholds,
ranks = [100, 0, 0],
}: Props) {
const palette = euiPaletteForStatus(3);
const [inFocusInd, setInFocusInd] = useState<number | null>(null);
const biggestValIndex = ranks.indexOf(Math.max(...ranks));
return (
<>
<EuiStat
titleSize="s"
title={value}
description={title}
titleColor={palette[biggestValIndex]}
isLoading={loading}
/>
<EuiSpacer size="s" />
<EuiFlexGroup
gutterSize="none"
alignItems="flexStart"
style={{ maxWidth: 340 }}
responsive={false}
>
{palette.map((hexCode, ind) => (
<ColorPaletteFlexItem
hexCode={hexCode}
key={hexCode}
position={ind}
inFocus={inFocusInd !== ind && inFocusInd !== null}
percentage={ranks[ind]}
tooltip={getCoreVitalTooltipMessage(
thresholds,
ind,
title,
ranks[ind]
)}
/>
))}
</EuiFlexGroup>
<EuiSpacer size="s" />
<PaletteLegends
ranks={ranks}
thresholds={thresholds}
title={title}
onItemHover={(ind) => {
setInFocusInd(ind);
}}
/>
<EuiSpacer size="xl" />
</>
);
}

View file

@ -0,0 +1,69 @@
/*
* 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,
EuiHealth,
euiPaletteForStatus,
EuiToolTip,
} from '@elastic/eui';
import styled from 'styled-components';
import { getCoreVitalTooltipMessage, Thresholds } from './CoreVitalItem';
const PaletteLegend = styled(EuiHealth)`
&:hover {
cursor: pointer;
text-decoration: underline;
background-color: #e7f0f7;
}
`;
interface Props {
onItemHover: (ind: number | null) => void;
ranks: number[];
thresholds: Thresholds;
title: string;
}
export function PaletteLegends({
ranks,
title,
onItemHover,
thresholds,
}: Props) {
const palette = euiPaletteForStatus(3);
return (
<EuiFlexGroup responsive={false}>
{palette.map((color, ind) => (
<EuiFlexItem
key={ind}
grow={false}
onMouseEnter={() => {
onItemHover(ind);
}}
onMouseLeave={() => {
onItemHover(null);
}}
>
<EuiToolTip
content={getCoreVitalTooltipMessage(
thresholds,
ind,
title,
ranks[ind]
)}
position="bottom"
>
<PaletteLegend color={color}>{ranks?.[ind]}%</PaletteLegend>
</EuiToolTip>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,93 @@
/*
* 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 { storiesOf } from '@storybook/react';
import React from 'react';
import { EuiThemeProvider } from '../../../../../../../observability/public';
import { CoreVitalItem } from '../CoreVitalItem';
import { LCP_LABEL } from '../translations';
storiesOf('app/RumDashboard/WebCoreVitals', module)
.addDecorator((storyFn) => <EuiThemeProvider>{storyFn()}</EuiThemeProvider>)
.add(
'Basic',
() => {
return (
<CoreVitalItem
thresholds={{ good: '0.1', bad: '0.25' }}
title={LCP_LABEL}
value={'0.00s'}
loading={false}
/>
);
},
{
info: {
propTables: false,
source: false,
},
}
)
.add(
'50% Good',
() => {
return (
<CoreVitalItem
thresholds={{ good: '0.1', bad: '0.25' }}
title={LCP_LABEL}
value={'0.00s'}
loading={false}
ranks={[50, 25, 25]}
/>
);
},
{
info: {
propTables: false,
source: false,
},
}
)
.add(
'100% Bad',
() => {
return (
<CoreVitalItem
thresholds={{ good: '0.1', bad: '0.25' }}
title={LCP_LABEL}
value={'0.00s'}
loading={false}
ranks={[0, 0, 100]}
/>
);
},
{
info: {
propTables: false,
source: false,
},
}
)
.add(
'100% Average',
() => {
return (
<CoreVitalItem
thresholds={{ good: '0.1', bad: '0.25' }}
title={LCP_LABEL}
value={'0.00s'}
loading={false}
ranks={[0, 100, 0]}
/>
);
},
{
info: {
propTables: false,
source: false,
},
}
);

View file

@ -0,0 +1,73 @@
/*
* 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 * 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';
const CoreVitalsThresholds = {
LCP: { good: '2.5s', bad: '4.0s' },
FID: { good: '100ms', bad: '300ms' },
CLS: { good: '0.1', bad: '0.25' },
};
export function CoreVitals() {
const { urlParams, uiFilters } = useUrlParams();
const { start, end, serviceName } = urlParams;
const { data, status } = useFetcher(
(callApmApi) => {
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, serviceName, uiFilters]
);
const { lcp, lcpRanks, fid, fidRanks, cls, clsRanks } = data || {};
return (
<EuiFlexGroup gutterSize="xl" justifyContent={'spaceBetween'}>
<EuiFlexItem>
<CoreVitalItem
title={LCP_LABEL}
value={lcp ? lcp + 's' : '0'}
ranks={lcpRanks}
loading={status !== 'success'}
thresholds={CoreVitalsThresholds.LCP}
/>
</EuiFlexItem>
<EuiFlexItem>
<CoreVitalItem
title={FID_LABEL}
value={fid ? fid + 's' : '0'}
ranks={fidRanks}
loading={status !== 'success'}
thresholds={CoreVitalsThresholds.FID}
/>
</EuiFlexItem>
<EuiFlexItem>
<CoreVitalItem
title={CLS_LABEL}
value={cls ?? '0'}
ranks={clsRanks}
loading={status !== 'success'}
thresholds={CoreVitalsThresholds.CLS}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,50 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const LCP_LABEL = i18n.translate('xpack.apm.rum.coreVitals.lcp', {
defaultMessage: 'Largest contentful paint',
});
export const FID_LABEL = i18n.translate('xpack.apm.rum.coreVitals.fip', {
defaultMessage: 'First input delay',
});
export const CLS_LABEL = i18n.translate('xpack.apm.rum.coreVitals.cls', {
defaultMessage: 'Cumulative layout shift',
});
export const FCP_LABEL = i18n.translate('xpack.apm.rum.coreVitals.fcp', {
defaultMessage: 'First contentful paint',
});
export const TBT_LABEL = i18n.translate('xpack.apm.rum.coreVitals.tbt', {
defaultMessage: 'Total blocking time',
});
export const POOR_LABEL = i18n.translate('xpack.apm.rum.coreVitals.poor', {
defaultMessage: 'a poor',
});
export const GOOD_LABEL = i18n.translate('xpack.apm.rum.coreVitals.good', {
defaultMessage: 'a good',
});
export const AVERAGE_LABEL = i18n.translate(
'xpack.apm.rum.coreVitals.average',
{
defaultMessage: 'an average',
}
);
export const MORE_LABEL = i18n.translate('xpack.apm.rum.coreVitals.more', {
defaultMessage: 'more',
});
export const LESS_LABEL = i18n.translate('xpack.apm.rum.coreVitals.less', {
defaultMessage: 'less',
});

View file

@ -0,0 +1,53 @@
/*
* 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 {
EuiButtonEmpty,
EuiHideFor,
EuiShowFor,
EuiButtonIcon,
} from '@elastic/eui';
import { I18LABELS } from '../translations';
import { PercentileRange } from './index';
interface Props {
percentileRange: PercentileRange;
setPercentileRange: (value: PercentileRange) => void;
}
export function ResetPercentileZoom({
percentileRange,
setPercentileRange,
}: Props) {
const isDisabled =
percentileRange.min === null && percentileRange.max === null;
const onClick = () => {
setPercentileRange({ min: null, max: null });
};
return (
<>
<EuiShowFor sizes={['xs']}>
<EuiButtonIcon
iconType="inspect"
size="s"
aria-label={I18LABELS.resetZoom}
onClick={onClick}
disabled={isDisabled}
/>
</EuiShowFor>
<EuiHideFor sizes={['xs']}>
<EuiButtonEmpty
iconType="inspect"
size="s"
onClick={onClick}
disabled={isDisabled}
>
{I18LABELS.resetZoom}
</EuiButtonEmpty>
</EuiHideFor>
</>
);
}

View file

@ -5,19 +5,14 @@
*/
import React, { useState } from 'react';
import {
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
import { useUrlParams } from '../../../../hooks/useUrlParams';
import { useFetcher } from '../../../../hooks/useFetcher';
import { I18LABELS } from '../translations';
import { BreakdownFilter } from '../Breakdowns/BreakdownFilter';
import { PageLoadDistChart } from '../Charts/PageLoadDistChart';
import { BreakdownItem } from '../../../../../typings/ui_filters';
import { ResetPercentileZoom } from './ResetPercentileZoom';
export interface PercentileRange {
min?: number | null;
@ -74,18 +69,10 @@ export function PageLoadDistribution() {
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="inspect"
size="s"
onClick={() => {
setPercentileRange({ min: null, max: null });
}}
disabled={
percentileRange.min === null && percentileRange.max === null
}
>
{I18LABELS.resetZoom}
</EuiButtonEmpty>
<ResetPercentileZoom
percentileRange={percentileRange}
setPercentileRange={setPercentileRange}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: 170 }}>
<BreakdownFilter

View file

@ -17,6 +17,7 @@ import { PageViewsTrend } from './PageViewsTrend';
import { PageLoadDistribution } from './PageLoadDistribution';
import { I18LABELS } from './translations';
import { VisitorBreakdown } from './VisitorBreakdown';
import { CoreVitals } from './CoreVitals';
export function RumDashboard() {
return (
@ -26,7 +27,7 @@ export function RumDashboard() {
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={1} data-cy={`client-metrics`}>
<EuiTitle size="xs">
<h3>{I18LABELS.pageLoadTimes}</h3>
<h3>{I18LABELS.pageLoadDuration}</h3>
</EuiTitle>
<EuiSpacer size="s" />
<ClientMetrics />
@ -34,6 +35,19 @@ export function RumDashboard() {
</EuiFlexGroup>
</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>
</EuiFlexItem>
<EuiFlexItem>
<EuiPanel>
<EuiFlexGroup justifyContent="spaceBetween">

View file

@ -25,6 +25,12 @@ export const I18LABELS = {
pageLoadTimes: i18n.translate('xpack.apm.rum.dashboard.pageLoadTimes.label', {
defaultMessage: 'Page load times',
}),
pageLoadDuration: i18n.translate(
'xpack.apm.rum.dashboard.pageLoadDuration.label',
{
defaultMessage: 'Page load duration',
}
),
pageLoadDistribution: i18n.translate(
'xpack.apm.rum.dashboard.pageLoadDistribution.label',
{
@ -46,6 +52,9 @@ export const I18LABELS = {
seconds: i18n.translate('xpack.apm.rum.filterGroup.seconds', {
defaultMessage: 'seconds',
}),
coreWebVitals: i18n.translate('xpack.apm.rum.filterGroup.coreWebVitals', {
defaultMessage: 'Core web vitals',
}),
};
export const VisitorBreakdownLabel = i18n.translate(

View file

@ -0,0 +1,123 @@
/*
* 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 { getRumOverviewProjection } from '../../projections/rum_overview';
import { mergeProjection } from '../../projections/util/merge_projection';
import {
Setup,
SetupTimeRange,
SetupUIFilters,
} from '../helpers/setup_request';
import {
CLS_FIELD,
FID_FIELD,
LCP_FIELD,
} from '../../../common/elasticsearch_fieldnames';
export async function getWebCoreVitals({
setup,
}: {
setup: Setup & SetupTimeRange & SetupUIFilters;
}) {
const projection = getRumOverviewProjection({
setup,
});
const params = mergeProjection(projection, {
body: {
size: 0,
query: {
bool: {
filter: [
...projection.body.query.bool.filter,
{
term: {
'user_agent.name': 'Chrome',
},
},
],
},
},
aggs: {
lcp: {
percentiles: {
field: LCP_FIELD,
percents: [50],
},
},
fid: {
percentiles: {
field: FID_FIELD,
percents: [50],
},
},
cls: {
percentiles: {
field: CLS_FIELD,
percents: [50],
},
},
lcpRanks: {
percentile_ranks: {
field: LCP_FIELD,
values: [2500, 4000],
keyed: false,
},
},
fidRanks: {
percentile_ranks: {
field: FID_FIELD,
values: [100, 300],
keyed: false,
},
},
clsRanks: {
percentile_ranks: {
field: CLS_FIELD,
values: [0.1, 0.25],
keyed: false,
},
},
},
},
});
const { apmEventClient } = setup;
const response = await apmEventClient.search(params);
const {
lcp,
cls,
fid,
lcpRanks,
fidRanks,
clsRanks,
} = response.aggregations!;
const getRanksPercentages = (
ranks: Array<{ key: number; value: number }>
) => {
const ranksVal = (ranks ?? [0, 0]).map(
({ value }) => value?.toFixed(0) ?? 0
);
return [
Number(ranksVal?.[0]),
Number(ranksVal?.[1]) - Number(ranksVal?.[0]),
100 - Number(ranksVal?.[1]),
];
};
// 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),
lcpRanks: getRanksPercentages(lcpRanks.values),
fidRanks: getRanksPercentages(fidRanks.values),
clsRanks: getRanksPercentages(clsRanks.values),
};
}

View file

@ -77,6 +77,7 @@ import {
rumPageLoadDistBreakdownRoute,
rumServicesRoute,
rumVisitorsBreakdownRoute,
rumWebCoreVitals,
} from './rum_client';
import {
observabilityOverviewHasDataRoute,
@ -172,6 +173,7 @@ const createApmApi = () => {
.add(rumClientMetricsRoute)
.add(rumServicesRoute)
.add(rumVisitorsBreakdownRoute)
.add(rumWebCoreVitals)
// Observability dashboard
.add(observabilityOverviewHasDataRoute)

View file

@ -14,6 +14,7 @@ import { getPageLoadDistribution } from '../lib/rum_client/get_page_load_distrib
import { getPageLoadDistBreakdown } from '../lib/rum_client/get_pl_dist_breakdown';
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';
export const percentileRangeRt = t.partial({
minPercentile: t.string,
@ -117,3 +118,15 @@ export const rumVisitorsBreakdownRoute = createRoute(() => ({
return getVisitorBreakdown({ setup });
},
}));
export const rumWebCoreVitals = createRoute(() => ({
path: '/api/apm/rum-client/web-core-vitals',
params: {
query: t.intersection([uiFiltersRt, rangeRt]),
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
return getWebCoreVitals({ setup });
},
}));

View file

@ -146,7 +146,7 @@ export interface AggregationOptionsByType {
buckets: number;
} & AggregationSourceOptions;
percentile_ranks: {
values: string[];
values: Array<string | number>;
keyed?: boolean;
hdr?: { number_of_significant_value_digits: number };
} & AggregationSourceOptions;