diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap
index 6238fbfdaa1a..f93df9a01dea 100644
--- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap
+++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap
@@ -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`;
diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts
index c13169549a56..b322abeb3d59 100644
--- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts
+++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts
@@ -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';
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx
index e21dd0d6ff12..b2132c50dc6b 100644
--- a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx
+++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx
@@ -56,7 +56,7 @@ export function ClientMetrics() {
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/ColorPaletteFlexItem.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/ColorPaletteFlexItem.tsx
new file mode 100644
index 000000000000..fc2390acde0b
--- /dev/null
+++ b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/ColorPaletteFlexItem.tsx
@@ -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 (
+
+
+
+
+
+ );
+}
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/CoreVitalItem.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/CoreVitalItem.tsx
new file mode 100644
index 000000000000..a4cbebf20b54
--- /dev/null
+++ b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/CoreVitalItem.tsx
@@ -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(null);
+
+ const biggestValIndex = ranks.indexOf(Math.max(...ranks));
+
+ return (
+ <>
+
+
+
+ {palette.map((hexCode, ind) => (
+
+ ))}
+
+
+ {
+ setInFocusInd(ind);
+ }}
+ />
+
+ >
+ );
+}
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/PaletteLegends.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/PaletteLegends.tsx
new file mode 100644
index 000000000000..84cc5f1ddb23
--- /dev/null
+++ b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/PaletteLegends.tsx
@@ -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 (
+
+ {palette.map((color, ind) => (
+ {
+ onItemHover(ind);
+ }}
+ onMouseLeave={() => {
+ onItemHover(null);
+ }}
+ >
+
+ {ranks?.[ind]}%
+
+
+ ))}
+
+ );
+}
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/__stories__/CoreVitals.stories.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/__stories__/CoreVitals.stories.tsx
new file mode 100644
index 000000000000..a611df00f1e6
--- /dev/null
+++ b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/__stories__/CoreVitals.stories.tsx
@@ -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) => {storyFn()})
+ .add(
+ 'Basic',
+ () => {
+ return (
+
+ );
+ },
+ {
+ info: {
+ propTables: false,
+ source: false,
+ },
+ }
+ )
+ .add(
+ '50% Good',
+ () => {
+ return (
+
+ );
+ },
+ {
+ info: {
+ propTables: false,
+ source: false,
+ },
+ }
+ )
+ .add(
+ '100% Bad',
+ () => {
+ return (
+
+ );
+ },
+ {
+ info: {
+ propTables: false,
+ source: false,
+ },
+ }
+ )
+ .add(
+ '100% Average',
+ () => {
+ return (
+
+ );
+ },
+ {
+ info: {
+ propTables: false,
+ source: false,
+ },
+ }
+ );
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/index.tsx
new file mode 100644
index 000000000000..e8305a6aef0d
--- /dev/null
+++ b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/index.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/translations.ts
new file mode 100644
index 000000000000..136dfb279e33
--- /dev/null
+++ b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/translations.ts
@@ -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',
+});
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/ResetPercentileZoom.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/ResetPercentileZoom.tsx
new file mode 100644
index 000000000000..deaeed70e572
--- /dev/null
+++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/ResetPercentileZoom.tsx
@@ -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 (
+ <>
+
+
+
+
+
+ {I18LABELS.resetZoom}
+
+
+ >
+ );
+}
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx
index 8fd03ebb65f4..f63b914c7339 100644
--- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx
+++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx
@@ -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() {
- {
- setPercentileRange({ min: null, max: null });
- }}
- disabled={
- percentileRange.min === null && percentileRange.max === null
- }
- >
- {I18LABELS.resetZoom}
-
+
- {I18LABELS.pageLoadTimes}
+ {I18LABELS.pageLoadDuration}
@@ -34,6 +35,19 @@ export function RumDashboard() {
+
+
+
+
+
+ {I18LABELS.coreWebVitals}
+
+
+
+
+
+
+
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts
index 66eeaf433d2a..042e138793f1 100644
--- a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts
+++ b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts
@@ -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(
diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_web_core_vitals.ts b/x-pack/plugins/apm/server/lib/rum_client/get_web_core_vitals.ts
new file mode 100644
index 000000000000..9395e5fe1433
--- /dev/null
+++ b/x-pack/plugins/apm/server/lib/rum_client/get_web_core_vitals.ts
@@ -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),
+ };
+}
diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts
index 5dff13e5b37e..cf7a02cde975 100644
--- a/x-pack/plugins/apm/server/routes/create_apm_api.ts
+++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts
@@ -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)
diff --git a/x-pack/plugins/apm/server/routes/rum_client.ts b/x-pack/plugins/apm/server/routes/rum_client.ts
index 0781512c6f7a..e17791f56eef 100644
--- a/x-pack/plugins/apm/server/routes/rum_client.ts
+++ b/x-pack/plugins/apm/server/routes/rum_client.ts
@@ -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 });
+ },
+}));
diff --git a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts
index f95761412254..7a7592b24896 100644
--- a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts
+++ b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts
@@ -146,7 +146,7 @@ export interface AggregationOptionsByType {
buckets: number;
} & AggregationSourceOptions;
percentile_ranks: {
- values: string[];
+ values: Array;
keyed?: boolean;
hdr?: { number_of_significant_value_digits: number };
} & AggregationSourceOptions;