[RUM Dashboard] Added rum core web vitals (#75685)
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
caa4e0c11b
commit
e827a6761e
|
@ -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`;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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'}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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(
|
||||
|
|
123
x-pack/plugins/apm/server/lib/rum_client/get_web_core_vitals.ts
Normal file
123
x-pack/plugins/apm/server/lib/rum_client/get_web_core_vitals.ts
Normal 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),
|
||||
};
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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 });
|
||||
},
|
||||
}));
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue