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;